Repository: denoland/deno
Pull request: fix(jupyter): handle shutdown and interrupt requests per protocol

## Summary

- Send `shutdown_reply` on the control channel before exiting, with the `restart` field echoed back from the request
- Handle `interrupt_request` by calling `v8::IsolateHandle::terminate_execution()` to actually interrupt running JS code (e.g. infinite loops), then send `interrupt_reply`
- Clear the V8 termination flag (`cancel_terminate_execution()`) before each evaluation so the kernel remains usable after an interrupt
- Implement `is_complete_request` with bracket/brace/paren balancing instead of always replying "complete" — also handles unterminated strings, template literals, and comments
- Add integration tests for shutdown reply, interrupt reply, and interrupt of running code with subsequent execution
- Add 16 unit tests for `is_complete` logic

Verified against the [kernel-testbed](https://github.com/runtimed/kernel-testbed) conformance suite: **26/26 (100%)**, up from 92%.

Closes #32357

## Test plan

- [x] `jupyter_shutdown_reply` — sends shutdown, verifies reply content and clean process exit
- [x] `jupyter_shutdown_restart_reply` — verifies `restart: true` is echoed back
- [x] `jupyter_interrupt_reply` — verifies reply on control channel
- [x] `jupyter_interrupt_running_code` — starts `while (true) {}`, interrupts it, verifies the kernel can still execute code afterward
- [x] 16 unit tests for `check_is_complete` covering balanced/unbalanced brackets, strings, comments, template literals, and edge cases
- [x] All pre-existing jupyter tests continue to pass
- [x] Full kernel-testbed conformance suite passes 26/26

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Changed files:
filename: cli/tools/jupyter/mod.rs
patch: @@ -180,12 +180,20 @@ pub async fn kernel(
   };
   let repl_session_proxy_channels = JupyterReplProxy { tx: tx1, rx: rx2 };
 
+  let isolate_handle = repl_session_proxy
+    .repl_session
+    .worker
+    .js_runtime
+    .v8_isolate()
+    .thread_safe_handle();
+
   let join_handle = std::thread::spawn(move || {
     let fut = server::JupyterServer::start(
       spec,
       stdio_rx,
       repl_session_proxy_channels,
       startup_data_tx,
+      isolate_handle,
     )
     .boxed_local();
     deno_runtime::tokio_util::create_and_run_current_thread(fut)
@@ -522,6 +530,14 @@ impl JupyterReplSession {
     &mut self,
     line: &str,
   ) -> Result<repl::TsEvaluateResponse, AnyError> {
+    // Clear any pending termination flag from a previous interrupt request,
+    // so that the next evaluation can proceed normally.
+    self
+      .repl_session
+      .worker
+      .js_runtime
+      .v8_isolate()
+      .cancel_terminate_execution();
     self
       .repl_session
       .evaluate_line_with_object_wrapping(line)

filename: cli/tools/jupyter/server.rs
patch: @@ -18,6 +18,7 @@ use deno_core::error::AnyError;
 use deno_core::futures;
 use deno_core::parking_lot::Mutex;
 use deno_core::serde_json;
+use deno_core::v8;
 use deno_lib::version::DENO_VERSION_INFO;
 use jupyter_protocol::ConnectionInfo;
 use jupyter_protocol::ExecutionCount;
@@ -61,6 +62,7 @@ impl JupyterServer {
     mut stdio_rx: mpsc::UnboundedReceiver<StreamContent>,
     repl_session_proxy: JupyterReplProxy,
     setup_tx: oneshot::Sender<StartupData>,
+    isolate_handle: v8::IsolateHandle,
   ) -> Result<(), AnyError> {
     let session_id = Uuid::new_v4().to_string();
 
@@ -146,8 +148,12 @@ impl JupyterServer {
     let control_fut = deno_core::unsync::spawn({
       let cancel_handle = cancel_handle.clone();
       async move {
-        if let Err(err) =
-          Self::handle_control(control_connection, cancel_handle).await
+        if let Err(err) = Self::handle_control(
+          control_connection,
+          cancel_handle,
+          isolate_handle,
+        )
+        .await
         {
           log::error!("Control error: {}", err);
         }
@@ -212,6 +218,7 @@ impl JupyterServer {
   async fn handle_control(
     mut connection: KernelControlConnection,
     cancel_handle: Rc<CancelHandle>,
+    isolate_handle: v8::IsolateHandle,
   ) -> Result<(), AnyError> {
     loop {
       let msg = connection.read().await?;
@@ -223,11 +230,30 @@ impl JupyterServer {
           // and it's no harm to send a kernel info reply on control
           connection.send(kernel_info().as_child_of(&msg)).await?;
         }
-        JupyterMessageContent::ShutdownRequest(_) => {
+        JupyterMessageContent::ShutdownRequest(ref req) => {
+          connection
+            .send(
+              messaging::ShutdownReply {
+                restart: req.restart,
+                status: ReplyStatus::Ok,
+                error: None,
+              }
+              .as_child_of(&msg),
+            )
+            .await?;
           cancel_handle.cancel();
         }
         JupyterMessageContent::InterruptRequest(_) => {
-          log::error!("Interrupt request currently not supported");
+          isolate_handle.terminate_execution();
+          connection
+            .send(
+              messaging::InterruptReply {
+                status: ReplyStatus::Ok,
+                error: None,
+              }
+              .as_child_of(&msg),
+            )
+            .await?;
         }
         JupyterMessageContent::DebugRequest(_) => {
           log::error!("Debug request currently not supported");
@@ -407,10 +433,9 @@ impl JupyterServer {
           .await?;
       }
 
-      JupyterMessageContent::IsCompleteRequest(_) => {
-        connection
-          .send(messaging::IsCompleteReply::complete().as_child_of(parent))
-          .await?;
+      JupyterMessageContent::IsCompleteRequest(req) => {
+        let reply = check_is_complete(&req.code);
+        connection.send(reply.as_child_of(parent)).await?;
       }
       JupyterMessageContent::KernelInfoRequest(_) => {
         connection.send(kernel_info().as_child_of(parent)).await?;
@@ -729,6 +754,87 @@ async fn publish_result(
   Ok(None)
 }
 
+/// Check whether code is complete (all brackets/braces/parens balanced
+/// and no trailing backslash or unterminated template literal).
+/// This is a heuristic — it ignores delimiters inside strings and comments
+/// but covers the common interactive cases.
+fn check_is_complete(code: &str) -> messaging::IsCompleteReply {
+  let mut stack: Vec<char> = Vec::new();
+  let mut chars = code.chars().peekable();
+  while let Some(ch) = chars.next() {
+    match ch {
+      // Skip single-line comments
+      '/' if chars.peek() == Some(&'/') => {
+        for c in chars.by_ref() {
+          if c == '\n' {
+            break;
+          }
+        }
+      }
+      // Skip multi-line comments
+      '/' if chars.peek() == Some(&'*') => {
+        chars.next(); // consume '*'
+        let mut closed = false;
+        while let Some(c) = chars.next() {
+          if c == '*' && chars.peek() == Some(&'/') {
+            chars.next();
+            closed = true;
+            break;
+          }
+        }
+        if !closed {
+          return messaging::IsCompleteReply::incomplete("".into());
+        }
+      }
+      // Skip string literals
+      '\'' | '"' | '`' => {
+        let quote = ch;
+        let mut escaped = false;
+        let mut closed = false;
+        for c in chars.by_ref() {
+          if escaped {
+            escaped = false;
+            continue;
+          }
+          if c == '\\' {
+            escaped = true;
+            continue;
+          }
+          if c == quote {
+            closed = true;
+            break;
+          }
+        }
+        if !closed {
+          return messaging::IsCompleteReply::incomplete("".into());
+        }
+      }
+      '(' | '[' | '{' => stack.push(ch),
+      ')' => {
+        if stack.pop() != Some('(') {
+          return messaging::IsCompleteReply::invalid();
+        }
+      }
+      ']' => {
+        if stack.pop() != Some('[') {
+          return messaging::IsCompleteReply::invalid();
+        }
+      }
+      '}' => {
+        if stack.pop() != Some('{') {
+          return messaging::IsCompleteReply::invalid();
+        }
+      }
+      _ => {}
+    }
+  }
+  if stack.is_empty() {
+    messaging::IsCompleteReply::complete()
+  } else {
+    messaging::IsCompleteReply::incomplete("  ".into())
+  }
+}
+
 // TODO(bartlomieju): dedup with repl::editor
 fn get_expr_from_line_at_pos(line: &str, cursor_pos: usize) -> &str {
   let start = line[..cursor_pos].rfind(is_word_boundary).unwrap_or(0);
@@ -831,3 +937,106 @@ async fn evaluate_expression(
     Some(evaluate_response)
   }
 }
+
+#[cfg(test)]
+mod tests {
+  use jupyter_protocol::messaging::IsCompleteReplyStatus;
+
+  use super::*;
+
+  #[test]
+  fn test_complete_simple_statement() {
+    let reply = check_is_complete("const x = 1");
+    assert_eq!(reply.status, IsCompleteReplyStatus::Complete);
+  }
+
+  #[test]
+  fn test_incomplete_open_brace() {
+    let reply = check_is_complete("const x = {");
+    assert_eq!(reply.status, IsCompleteReplyStatus::Incomplete);
+  }
+
+  #[test]
+  fn test_incomplete_open_paren() {
+    let reply = check_is_complete("function foo(");
+    assert_eq!(reply.status, IsCompleteReplyStatus::Incomplete);
+  }
+
+  #[test]
+  fn test_incomplete_open_bracket() {
+    let reply = check_is_complete("const arr = [1, 2,");
+    assert_eq!(reply.status, IsCompleteReplyStatus::Incomplete);
+  }
+
+  #[test]
+  fn test_complete_balanced_braces() {
+    let reply = check_is_complete("if (true) { console.log(1) }");
+    assert_eq!(reply.status, IsCompleteReplyStatus::Complete);
+  }
+
+  #[test]
+  fn test_complete_multiline() {
+    let reply = check_is_complete("function foo() {\n  return 1;\n}");
+    assert_eq!(reply.status, IsCompleteReplyStatus::Complete);
+  }
+
+  #[test]
+  fn test_invalid_extra_close() {
+    let reply = check_is_complete("const x = 1 }");
+    assert_eq!(reply.status, IsCompleteReplyStatus::Invalid);
+  }
+
+  #[test]
+  fn test_invalid_mismatched() {
+    let reply = check_is_complete("const x = (]");
+    assert_eq!(reply.status, IsCompleteReplyStatus::Invalid);
+  }
+
+  #[test]
+  fn test_ignores_brackets_in_strings() {
+    let reply = check_is_complete(r#"const x = "hello { world""#);
+    assert_eq!(reply.status, IsCompleteReplyStatus::Complete);
+  }
+
+  #[test]
+  fn test_ignores_brackets_in_single_line_comment() {
+    let reply = check_is_complete("const x = 1 // {");
+    assert_eq!(reply.status, IsCompleteReplyStatus::Complete);
+  }
+
+  #[test]
+  fn test_ignores_brackets_in_multiline_comment() {
+    let reply = check_is_complete("const x = 1 /* { */");
+    assert_eq!(reply.status, IsCompleteReplyStatus::Complete);
+  }
+
+  #[test]
+  fn test_incomplete_unterminated_string() {
+    let reply = check_is_complete(r#"const x = "hello"#);
+    assert_eq!(reply.status, IsCompleteReplyStatus::Incomplete);
+  }
+
+  #[test]
+  fn test_incomplete_unterminated_template_literal() {
+    let reply = check_is_complete("const x = `hello ${name}");
+    assert_eq!(reply.status, IsCompleteReplyStatus::Incomplete);
+  }
+
+  #[test]
+  fn test_incomplete_unterminated_multiline_comment() {
+    let reply = check_is_complete("/* this comment never ends");
+    assert_eq!(reply.status, IsCompleteReplyStatus::Incomplete);
+  }
+
+  #[test]
+  fn test_complete_empty() {
+    let reply = check_is_complete("");
+    assert_eq!(reply.status, IsCompleteReplyStatus::Complete);
+  }
+
+  #[test]
+  fn test_escaped_quote_in_string() {
+    let reply = check_is_complete(r#"const x = "hello \" world""#);
+    assert_eq!(reply.status, IsCompleteReplyStatus::Complete);
+  }
+}

filename: tests/integration/jupyter_tests.rs
patch: @@ -629,6 +629,129 @@ async fn jupyter_store_history_false() -> Result<()> {
   Ok(())
 }
 
+#[test]
+async fn jupyter_shutdown_reply() -> Result<()> {
+  let (_ctx, client, process) = setup().await;
+  client
+    .send(
+      Control,
+      "shutdown_request",
+      json!({
+        "restart": false,
+      }),
+    )
+    .await?;
+  let msg = client.recv(Control).await?;
+  assert_eq!(msg.header.msg_type, "shutdown_reply");
+  assert_json_subset(
+    msg.content,
+    json!({
+      "status": "ok",
+      "restart": false,
+    }),
+  );
+
+  // The kernel should exit after the shutdown request
+  let output = process.wait_or_kill(Duration::from_secs(5)).await;
+  assert!(output.status.success());
+
+  Ok(())
+}
+
+#[test]
+async fn jupyter_shutdown_restart_reply() -> Result<()> {
+  let (_ctx, client, _process) = setup().await;
+  client
+    .send(
+      Control,
+      "shutdown_request",
+      json!({
+        "restart": true,
+      }),
+    )
+    .await?;
+  let msg = client.recv(Control).await?;
+  assert_eq!(msg.header.msg_type, "shutdown_reply");
+  assert_json_subset(
+    msg.content,
+    json!({
+      "status": "ok",
+      "restart": true,
+    }),
+  );
+
+  Ok(())
+}
+
+#[test]
+async fn jupyter_interrupt_reply() -> Result<()> {
+  let (_ctx, client, _process) = setup().await;
+  client.send(Control, "interrupt_request", json!({})).await?;
+  let msg = client.recv(Control).await?;
+  assert_eq!(msg.header.msg_type, "interrupt_reply");
+  assert_json_subset(
+    msg.content,
+    json!({
+      "status": "ok",
+    }),
+  );
+
+  Ok(())
+}
+
+#[test]
+async fn jupyter_interrupt_running_code() -> Result<()> {
+  let (_ctx, client, _process) = setup().await;
+
+  // Start an infinite loop
+  client
+    .send(
+      Shell,
+      "execute_request",
+      json!({
+        "silent": false,
+        "store_history": true,
+        "code": "while (true) {}",
+      }),
+    )
+    .await?;
+
+  // Give the code a moment to start executing
+  tokio::time::sleep(Duration::from_millis(100)).await;
+
+  // Send interrupt request on the control channel
+  client.send(Control, "interrupt_request", json!({})).await?;
+  let interrupt_reply = client.recv(Control).await?;
+  assert_eq!(interrupt_reply.header.msg_type, "interrupt_reply");
+
+  // The execute_request should complete (with an error due to interruption)
+  let reply = client.recv(Shell).await?;
+  assert_eq!(reply.header.msg_type, "execute_reply");
+
+  // Verify the kernel is still alive and can execute code after interrupt
+  client
+    .send(
+      Shell,
+      "execute_request",
+      json!({
+        "silent": false,
+        "store_history": true,
+        "code": "42",
+      }),
+    )
+    .await?;
+  let reply = client.recv(Shell).await?;
+  assert_eq!(reply.header.msg_type, "execute_reply");
+  assert_json_subset(
+    reply.content,
+    json!({
+      "status": "ok",
+    }),
+  );
+
+  Ok(())
+}
+
 #[test]
 async fn jupyter_http_server() -> Result<()> {
   let (_ctx, client, _process) = setup().await;