Files
oak-gpui/crates/repl/src/runtimes.rs
T
Kyle Kelley 221edfc267 Bring Jupyter to Zed Editing (#12062)
Run any Jupyter kernel in Zed on any buffer (editor):

<img width="1074" alt="image"
src="https://github.com/zed-industries/zed/assets/836375/eac8ed69-d02b-4d46-b379-6186d8f59470">

## TODO

### Lifecycle

* [x] Launch kernels on demand
* [x] Wait for kernel to be started
* [x] Request Kernel info on start
* [x] Show in progress indicator
* [ ] Allow picking kernel (it defaults to first matching language name)
* [ ] Menu for interrupting and shutting down the kernel
* [ ] Drop running kernels once editor is dropped

### Media Outputs

* [x] Render text and tracebacks with ANSI color handling
* [x] Render markdown as text
* [x] Render PNG and JPEG images using an explicit height based on
line-height
* ~~Render SVG~~ -- not happening for this PR due to lack of text in SVG
support
* [ ] Process `update_display_data` message and related `display_id`
* [x] Process `page` data from payloads as outputs
* [ ] Render markdown as, well, rendered markdown -- Note: unsure if we
can get line heights here

### Document

* [x] Select code and run
* [x] Run current line
* [x] Clear previous overlapping runs
* [ ] Support running markdown code blocks
* [ ] Action to export session as notebook or output files
* [ ] Action to clear all outputs
* [ ] Delete outputs when lines are deleted

## Other missing features

The following is a list of missing functionality or expectations that
are out of scope for this PR.

### Python Environments

Detecting python environments should probably be done in a separate PR
in tandem with how they're used with LSP. Users likely want to pick an
environment for their project, whether a virtualenv, conda env, pyenv,
poetry backed virtualenv, or the system. Related issues:

* https://github.com/zed-industries/zed/issues/7646
* https://github.com/zed-industries/zed/issues/7808
* https://github.com/zed-industries/zed/issues/7296

### LSP Integration

* Submit `complete_request` messages for completions to interleave
interactive variables with LSP
* LSP for IPython semantics (`%%timeit`, `!ls`, `get_ipython`, etc.)

## Future release notes

- Run code in any editor, whether it's a script or a markdown document

Release Notes:

- N/A
2024-06-17 10:02:31 -07:00

330 lines
10 KiB
Rust

use anyhow::{Context as _, Result};
use collections::HashMap;
use futures::lock::Mutex;
use futures::{channel::mpsc, SinkExt as _, StreamExt as _};
use gpui::{AsyncAppContext, EntityId};
use project::Fs;
use runtimelib::{dirs, ConnectionInfo, JupyterKernelspec, JupyterMessage, JupyterMessageContent};
use smol::{net::TcpListener, process::Command};
use std::fmt::Debug;
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::{path::PathBuf, sync::Arc};
#[derive(Debug)]
pub struct Request {
pub request: runtimelib::JupyterMessageContent,
pub responses_rx: mpsc::UnboundedSender<JupyterMessageContent>,
}
#[derive(Debug, Clone)]
pub struct RuntimeSpecification {
pub name: String,
pub path: PathBuf,
pub kernelspec: JupyterKernelspec,
}
impl RuntimeSpecification {
#[must_use]
fn command(&self, connection_path: &PathBuf) -> Result<Command> {
let argv = &self.kernelspec.argv;
if argv.is_empty() {
return Err(anyhow::anyhow!("Empty argv in kernelspec {}", self.name));
}
if argv.len() < 2 {
return Err(anyhow::anyhow!("Invalid argv in kernelspec {}", self.name));
}
if !argv.contains(&"{connection_file}".to_string()) {
return Err(anyhow::anyhow!(
"Missing 'connection_file' in argv in kernelspec {}",
self.name
));
}
let mut cmd = Command::new(&argv[0]);
for arg in &argv[1..] {
if arg == "{connection_file}" {
cmd.arg(connection_path);
} else {
cmd.arg(arg);
}
}
if let Some(env) = &self.kernelspec.env {
cmd.envs(env);
}
Ok(cmd)
}
}
// Find a set of open ports. This creates a listener with port set to 0. The listener will be closed at the end when it goes out of scope.
// There's a race condition between closing the ports and usage by a kernel, but it's inherent to the Jupyter protocol.
async fn peek_ports(ip: IpAddr) -> anyhow::Result<[u16; 5]> {
let mut addr_zeroport: SocketAddr = SocketAddr::new(ip, 0);
addr_zeroport.set_port(0);
let mut ports: [u16; 5] = [0; 5];
for i in 0..5 {
let listener = TcpListener::bind(addr_zeroport).await?;
let addr = listener.local_addr()?;
ports[i] = addr.port();
}
Ok(ports)
}
#[derive(Debug)]
pub struct RunningKernel {
#[allow(unused)]
runtime: RuntimeSpecification,
#[allow(unused)]
process: smol::process::Child,
pub request_tx: mpsc::UnboundedSender<Request>,
}
impl RunningKernel {
pub async fn new(
runtime: RuntimeSpecification,
entity_id: EntityId,
fs: Arc<dyn Fs>,
cx: AsyncAppContext,
) -> anyhow::Result<Self> {
let ip = IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1));
let ports = peek_ports(ip).await?;
let connection_info = ConnectionInfo {
transport: "tcp".to_string(),
ip: ip.to_string(),
stdin_port: ports[0],
control_port: ports[1],
hb_port: ports[2],
shell_port: ports[3],
iopub_port: ports[4],
signature_scheme: "hmac-sha256".to_string(),
key: uuid::Uuid::new_v4().to_string(),
kernel_name: Some(format!("zed-{}", runtime.name)),
};
let connection_path = dirs::runtime_dir().join(format!("kernel-zed-{}.json", entity_id));
let content = serde_json::to_string(&connection_info)?;
// write out file to disk for kernel
fs.atomic_write(connection_path.clone(), content).await?;
let mut cmd = runtime.command(&connection_path)?;
let process = cmd
// .stdout(Stdio::null())
// .stderr(Stdio::null())
.kill_on_drop(true)
.spawn()
.context("failed to start the kernel process")?;
let mut iopub = connection_info.create_client_iopub_connection("").await?;
let mut shell = connection_info.create_client_shell_connection().await?;
// Spawn a background task to handle incoming messages from the kernel as well
// as outgoing messages to the kernel
let child_messages: Arc<
Mutex<HashMap<String, mpsc::UnboundedSender<JupyterMessageContent>>>,
> = Default::default();
let (request_tx, mut request_rx) = mpsc::unbounded::<Request>();
cx.background_executor()
.spawn({
let child_messages = child_messages.clone();
async move {
let child_messages = child_messages.clone();
while let Ok(message) = iopub.read().await {
if let Some(parent_header) = message.parent_header {
let child_messages = child_messages.lock().await;
let sender = child_messages.get(&parent_header.msg_id);
match sender {
Some(mut sender) => {
sender.send(message.content).await?;
}
None => {}
}
}
}
anyhow::Ok(())
}
})
.detach();
cx.background_executor()
.spawn({
let child_messages = child_messages.clone();
async move {
while let Some(request) = request_rx.next().await {
let rx = request.responses_rx.clone();
let request: JupyterMessage = request.request.into();
let msg_id = request.header.msg_id.clone();
let mut sender = rx.clone();
child_messages
.lock()
.await
.insert(msg_id.clone(), sender.clone());
shell.send(request).await?;
let response = shell.read().await?;
sender.send(response.content).await?;
}
anyhow::Ok(())
}
})
.detach();
Ok(Self {
runtime,
process,
request_tx,
})
}
}
async fn read_kernelspec_at(
// Path should be a directory to a jupyter kernelspec, as in
// /usr/local/share/jupyter/kernels/python3
kernel_dir: PathBuf,
fs: Arc<dyn Fs>,
) -> anyhow::Result<RuntimeSpecification> {
let path = kernel_dir;
let kernel_name = if let Some(kernel_name) = path.file_name() {
kernel_name.to_string_lossy().to_string()
} else {
return Err(anyhow::anyhow!("Invalid kernelspec directory: {:?}", path));
};
if !fs.is_dir(path.as_path()).await {
return Err(anyhow::anyhow!("Not a directory: {:?}", path));
}
let expected_kernel_json = path.join("kernel.json");
let spec = fs.load(expected_kernel_json.as_path()).await?;
let spec = serde_json::from_str::<JupyterKernelspec>(&spec)?;
Ok(RuntimeSpecification {
name: kernel_name,
path,
kernelspec: spec,
})
}
/// Read a directory of kernelspec directories
async fn read_kernels_dir(
path: PathBuf,
fs: Arc<dyn Fs>,
) -> anyhow::Result<Vec<RuntimeSpecification>> {
let mut kernelspec_dirs = fs.read_dir(&path).await?;
let mut valid_kernelspecs = Vec::new();
while let Some(path) = kernelspec_dirs.next().await {
match path {
Ok(path) => {
if fs.is_dir(path.as_path()).await {
let fs = fs.clone();
if let Ok(kernelspec) = read_kernelspec_at(path, fs).await {
valid_kernelspecs.push(kernelspec);
}
}
}
Err(err) => {
log::warn!("Error reading kernelspec directory: {:?}", err);
}
}
}
Ok(valid_kernelspecs)
}
pub async fn get_runtime_specifications(
fs: Arc<dyn Fs>,
) -> anyhow::Result<Vec<RuntimeSpecification>> {
let data_dirs = dirs::data_dirs();
let kernel_dirs = data_dirs
.iter()
.map(|dir| dir.join("kernels"))
.map(|path| read_kernels_dir(path, fs.clone()))
.collect::<Vec<_>>();
let kernel_dirs = futures::future::join_all(kernel_dirs).await;
let kernel_dirs = kernel_dirs
.into_iter()
.filter_map(Result::ok)
.flatten()
.collect::<Vec<_>>();
Ok(kernel_dirs)
}
#[cfg(test)]
mod test {
use super::*;
use std::path::PathBuf;
use gpui::TestAppContext;
use project::FakeFs;
use serde_json::json;
#[gpui::test]
async fn test_get_kernelspecs(cx: &mut TestAppContext) {
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
"/jupyter",
json!({
".zed": {
"settings.json": r#"{ "tab_size": 8 }"#,
"tasks.json": r#"[{
"label": "cargo check",
"command": "cargo",
"args": ["check", "--all"]
},]"#,
},
"kernels": {
"python": {
"kernel.json": r#"{
"display_name": "Python 3",
"language": "python",
"argv": ["python3", "-m", "ipykernel_launcher", "-f", "{connection_file}"],
"env": {}
}"#
},
"deno": {
"kernel.json": r#"{
"display_name": "Deno",
"language": "typescript",
"argv": ["deno", "run", "--unstable", "--allow-net", "--allow-read", "https://deno.land/std/http/file_server.ts", "{connection_file}"],
"env": {}
}"#
}
},
}),
)
.await;
let mut kernels = read_kernels_dir(PathBuf::from("/jupyter/kernels"), fs)
.await
.unwrap();
kernels.sort_by(|a, b| a.name.cmp(&b.name));
assert_eq!(
kernels.iter().map(|c| c.name.clone()).collect::<Vec<_>>(),
vec!["deno", "python"]
);
}
}