mod binary;
mod common;
#[cfg(unix)]
use std::os::unix::fs::symlink;
#[cfg(windows)]
use std::os::windows::fs::symlink_dir;
#[cfg(target_os = "linux")]
use std::{env, ffi::OsStr, os::unix::ffi::OsStrExt};
use std::{
fmt::Write as _,
fs,
future::ready,
io,
path::{Path, PathBuf},
process::Command,
thread,
time::Duration,
};
use insta::assert_snapshot;
use monty_pool::{
Checkout, DEFAULT_MAX_CHECKOUTS_PER_WORKER, MountSpec, MountSpecMode, Pool, PoolConfig, PoolError, PrintFuture,
ReplConfig, ResumeValue, TurnEvent, on_print_sync,
};
#[cfg(unix)]
use monty_proto::{encode_framed_into, pb};
#[cfg(not(target_os = "linux"))]
use monty_types::ExecutionClock;
#[cfg(unix)]
use monty_types::SourceRange;
use monty_types::{
CallArgs, DateTimeSource, ExcType, IMPORT_CALL, IMPORT_STUBS_CALL, ImportArgs, ModuleFile, ModuleRead,
MontyException, MontyObject, MontyUuid, NameLookupResult, OsPolicy, PrintStream, RandomSeed, RandomStart,
ResourceLimits, SleepMode, TypeCheckingConfig, TypeCheckingFormat,
unstable::{self, MontyNode},
};
use tokio::time::sleep;
#[cfg(unix)]
use tokio::time::timeout;
use crate::{binary::monty_binary, common::wait_for_idle};
fn config() -> PoolConfig {
PoolConfig::subprocess(monty_binary())
}
fn no_print(_: PrintStream, _: &str) -> PrintFuture {
Box::pin(ready(()))
}
#[track_caller]
fn expect_complete(event: TurnEvent) -> MontyObject {
match event {
TurnEvent::Complete(value) => value,
other => panic!("expected Complete, got {other:?}"),
}
}
async fn feed_with_mounts(
checkout: &mut Checkout,
result: Result<TurnEvent, PoolError>,
) -> Result<TurnEvent, PoolError> {
let event = result?;
let mut event = event;
loop {
if !matches!(event, TurnEvent::OsCall { .. }) {
return Ok(event);
}
match checkout.resume_from_mounts(&mut no_print).await? {
Some(next) => event = next,
None => return Ok(event),
}
}
}
fn try_symlink_dir(original: &Path, link: &Path) -> io::Result<()> {
#[cfg(unix)]
return symlink(original, link);
#[cfg(windows)]
return symlink_dir(original, link);
}
fn kill_pid(pid: u32) {
#[cfg(unix)]
{
let status = Command::new("kill").args(["-9", &pid.to_string()]).status().unwrap();
assert!(status.success());
}
#[cfg(windows)]
{
let status = Command::new("taskkill")
.args(["/PID", &pid.to_string(), "/F"])
.status()
.unwrap();
assert!(status.success());
}
}
#[cfg(unix)]
fn write_fake_monty(dir: &Path, script: &str) -> PathBuf {
let fake = dir.join("monty");
let status = Command::new("sh")
.args(["-c", r#"printf '%s' "$2" > "$1" && chmod 755 "$1""#, "sh"])
.arg(&fake)
.arg(script)
.status()
.unwrap();
assert!(status.success(), "writing the stand-in `monty` failed");
fake
}
#[tokio::test]
async fn allow_eager_await_validates_replies_before_sending() {
let pool = Pool::new(config()).await.unwrap();
let mut session = pool.checkout(&ReplConfig::default()).await.unwrap();
let event = session
.feed("await f()", vec![], vec![], false, &mut no_print)
.await
.unwrap();
let TurnEvent::FunctionCall {
call_id,
allow_eager_await,
..
} = event
else {
panic!("expected function call, got {event:?}")
};
assert!(allow_eager_await);
for results in [
vec![],
vec![(call_id + 1, ResumeValue::Return(MontyObject::int(42)))],
vec![(call_id, ResumeValue::Future)],
vec![(call_id, ResumeValue::NotFound)],
] {
assert!(matches!(
session.resume_futures(results, &mut no_print).await,
Err(PoolError::Protocol(_))
));
}
let done = session
.resume_futures(
vec![(call_id, ResumeValue::Return(MontyObject::int(42)))],
&mut no_print,
)
.await
.unwrap();
assert_eq!(expect_complete(done), MontyObject::int(42));
session.finish().await.unwrap();
}
#[tokio::test(flavor = "multi_thread")]
async fn large_value_roundtrips_through_offloaded_decode() {
large_value_roundtrip().await;
}
#[tokio::test]
async fn large_value_roundtrips_on_current_thread_runtime() {
large_value_roundtrip().await;
}
async fn large_value_roundtrip() {
let pool = Pool::new(config()).await.unwrap();
let mut session = pool.checkout(&ReplConfig::default()).await.unwrap();
let event = session
.feed("'x' * 2_000_000", vec![], vec![], false, &mut no_print)
.await
.unwrap();
assert_eq!(expect_complete(event), MontyObject::string("x".repeat(2_000_000)));
let event = session
.feed("1 + 1", vec![], vec![], false, &mut no_print)
.await
.unwrap();
assert_eq!(expect_complete(event), MontyObject::int(2));
session.finish().await.unwrap();
}
#[tokio::test]
async fn feed_and_finish_reuses_the_worker() {
let pool = Pool::new(config()).await.unwrap();
let mut session = pool.checkout(&ReplConfig::default()).await.unwrap();
let first_pid = session.pid().unwrap();
let first_id = session.worker_id().unwrap();
let event = session
.feed("x = 40\nx + 2", vec![], vec![], false, &mut no_print)
.await
.unwrap();
assert_eq!(expect_complete(event), MontyObject::int(42));
let event = session
.feed("x * 2", vec![], vec![], false, &mut no_print)
.await
.unwrap();
assert_eq!(expect_complete(event), MontyObject::int(80));
session.finish().await.unwrap();
assert_eq!(pool.idle_workers(), 1);
let mut session = pool.checkout(&ReplConfig::default()).await.unwrap();
assert_eq!(session.pid().unwrap(), first_pid);
assert_eq!(session.worker_id(), Some(first_id));
let event = session.feed("x", vec![], vec![], false, &mut no_print).await.unwrap();
assert!(matches!(event, TurnEvent::NameLookup { name, .. } if name == "x"));
let err = session
.resume_name_lookup(NameLookupResult::Undefined, &mut no_print)
.await
.unwrap_err();
let PoolError::Runtime(exc) = err else {
panic!("expected Runtime, got {err:?}");
};
assert_eq!(exc.message(), Some("name 'x' is not defined"));
session.finish().await.unwrap();
}
#[tokio::test]
async fn name_lookup_error_is_raised_in_the_sandbox() {
let pool = Pool::new(config()).await.unwrap();
let mut session = pool.checkout(&ReplConfig::default()).await.unwrap();
let event = session
.feed(
"try:\n secret\nexcept PermissionError as e:\n caught = str(e)\ncaught",
vec![],
vec![],
false,
&mut no_print,
)
.await
.unwrap();
assert!(matches!(event, TurnEvent::NameLookup { ref name, .. } if name == "secret"));
let error = MontyException::new(ExcType::PermissionError, Some("secret is off limits".to_owned()));
let event = session.resume_name_lookup(error, &mut no_print).await.unwrap();
assert_eq!(
expect_complete(event),
MontyObject::string("secret is off limits".to_owned())
);
let event = session
.feed("secret", vec![], vec![], false, &mut no_print)
.await
.unwrap();
assert!(matches!(event, TurnEvent::NameLookup { ref name, .. } if name == "secret"));
let error = MontyException::new(ExcType::PermissionError, Some("still off limits".to_owned()));
let err = session.resume_name_lookup(error, &mut no_print).await.unwrap_err();
let PoolError::Runtime(exc) = err else {
panic!("expected Runtime, got {err:?}");
};
assert_eq!(exc.exc_type(), ExcType::PermissionError);
assert_eq!(exc.message(), Some("still off limits"));
assert!(
!exc.traceback().is_empty(),
"the sandbox frame must be on the traceback"
);
let event = session
.feed("1 + 1", vec![], vec![], false, &mut no_print)
.await
.unwrap();
assert_eq!(expect_complete(event), MontyObject::int(2));
session.finish().await.unwrap();
}
#[tokio::test]
async fn cyclic_return_value_decodes_and_keeps_the_worker_alive() {
let pool = Pool::new(config()).await.unwrap();
let mut session = pool.checkout(&ReplConfig::default()).await.unwrap();
let event = session
.feed("d = {}\nd['self'] = d\nd", vec![], vec![], false, &mut no_print)
.await
.unwrap();
let value = expect_complete(event);
let Some(pairs) = value.as_ref().pairs() else {
panic!("expected Dict");
};
assert_eq!(pairs.len(), 1);
assert_eq!(pairs[0].0, MontyObject::string("self".to_owned()));
assert!(matches!(unstable::node(pairs[0].1), MontyNode::Cycle(placeholder) if placeholder == "{...}"));
let event = session
.feed("1 + 1", vec![], vec![], false, &mut no_print)
.await
.unwrap();
assert_eq!(expect_complete(event), MontyObject::int(2));
session.finish().await.unwrap();
assert_eq!(pool.idle_workers(), 1);
}
#[tokio::test]
async fn deeply_nested_name_lookup_value_crosses_the_wire() {
let pool = Pool::new(config()).await.unwrap();
let mut session = pool.checkout(&ReplConfig::default()).await.unwrap();
let event = session
.feed("missing", vec![], vec![], false, &mut no_print)
.await
.unwrap();
assert!(matches!(event, TurnEvent::NameLookup { ref name, .. } if name == "missing"));
let deep = (0..100).fold(MontyObject::int(1), |inner, _| MontyObject::list([inner]));
let event = session
.resume_name_lookup(Some(deep.clone()), &mut no_print)
.await
.unwrap();
assert_eq!(expect_complete(event), deep);
session.finish().await.unwrap();
}
#[cfg(target_os = "linux")]
#[tokio::test]
async fn non_utf8_mount_path_works() {
let dir = tempfile::tempdir().unwrap();
let weird_dir = dir.path().join(OsStr::from_bytes(b"weird-\xff"));
fs::create_dir(&weird_dir).unwrap();
fs::write(weird_dir.join("data.txt"), "non-utf8 host dir").unwrap();
let pool = Pool::new(config()).await.unwrap();
let mut session = pool.checkout(&ReplConfig::default()).await.unwrap();
let result = session
.feed(
"from pathlib import Path\nPath('/mnt/data/data.txt').read_text()",
vec![],
vec![MountSpec::new("/mnt/data", weird_dir, MountSpecMode::ReadOnly).unwrap()],
false,
&mut no_print,
)
.await;
let event = feed_with_mounts(&mut session, result).await.unwrap();
assert_eq!(
expect_complete(event),
MontyObject::string("non-utf8 host dir".to_owned())
);
session.finish().await.unwrap();
}
#[tokio::test]
async fn invalid_mount_host_path_is_rejected_cleanly() {
let err = MountSpec::new(
"/mnt/data",
PathBuf::from("/nonexistent/monty/mount/dir"),
MountSpecMode::ReadOnly,
)
.unwrap_err();
let PoolError::Runtime(exc) = err else {
panic!("expected Runtime, got {err:?}");
};
assert!(
exc.message().is_some_and(|m| m.contains("cannot open host path")),
"unexpected message: {:?}",
exc.message()
);
let pool = Pool::new(config()).await.unwrap();
let mut session = pool.checkout(&ReplConfig::default()).await.unwrap();
let event = session
.feed("1 + 1", vec![], vec![], false, &mut no_print)
.await
.unwrap();
assert_eq!(expect_complete(event), MontyObject::int(2));
session.finish().await.unwrap();
}
#[tokio::test]
async fn working_directory_persists_across_feeds() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("data.txt"), "relative!").unwrap();
fs::create_dir(dir.path().join("sub")).unwrap();
let mount = || vec![MountSpec::new("/mnt", dir.path(), MountSpecMode::ReadOnly).unwrap()];
let pool = Pool::new(config()).await.unwrap();
let mut session = pool.checkout(&ReplConfig::default()).await.unwrap();
let code = "\
import os
from pathlib import Path
before = (os.getcwd(), __file__, open('data.txt').read())
os.chdir('sub')
(before, Path.cwd(), Path('..').resolve())";
let result = session.feed(code, vec![], mount(), false, &mut no_print).await;
let event = feed_with_mounts(&mut session, result).await.unwrap();
assert_eq!(
expect_complete(event),
MontyObject::tuple([
MontyObject::tuple([
MontyObject::string("/mnt".to_owned()),
MontyObject::string("/mnt/main.py".to_owned()),
MontyObject::string("relative!".to_owned()),
]),
MontyObject::path("/mnt/sub".to_owned()),
MontyObject::path("/mnt".to_owned()),
])
);
let event = session
.feed("os.getcwd()", vec![], vec![], false, &mut no_print)
.await
.unwrap();
assert_eq!(expect_complete(event), MontyObject::string("/mnt/sub".to_owned()));
let result = session
.feed_with_cwd("os.getcwd()", vec![], mount(), Some("/mnt/"), false, &mut no_print)
.await;
let event = feed_with_mounts(&mut session, result).await.unwrap();
assert_eq!(expect_complete(event), MontyObject::string("/mnt".to_owned()));
let err = session
.feed_with_cwd("1", vec![], vec![], Some("data"), false, &mut no_print)
.await
.unwrap_err();
let PoolError::Runtime(exc) = err else {
panic!("expected a runtime error, got {err:?}");
};
assert_eq!(exc.exc_type(), ExcType::ValueError);
assert_eq!(exc.message().unwrap(), "cwd must be an absolute POSIX path: \"data\"");
let event = session
.feed("os.getcwd()", vec![], vec![], false, &mut no_print)
.await
.unwrap();
assert_eq!(expect_complete(event), MontyObject::string("/mnt".to_owned()));
session.finish().await.unwrap();
let mut session = pool.checkout(&ReplConfig::default()).await.unwrap();
let event = session
.feed(
"import os\n(os.getcwd(), __file__)",
vec![],
vec![],
false,
&mut no_print,
)
.await
.unwrap();
assert_eq!(
expect_complete(event),
MontyObject::tuple([
MontyObject::string("/".to_owned()),
MontyObject::string("/main.py".to_owned())
])
);
let result = session.feed("os.getcwd()", vec![], mount(), false, &mut no_print).await;
let event = feed_with_mounts(&mut session, result).await.unwrap();
assert_eq!(expect_complete(event), MontyObject::string("/".to_owned()));
session.finish().await.unwrap();
}
#[tokio::test]
async fn a_module_file_in_a_mount_is_imported() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("helpers.py"), "def double(x):\n return x * 2\n").unwrap();
fs::create_dir(dir.path().join("pkg")).unwrap();
fs::write(dir.path().join("pkg/__init__.py"), "").unwrap();
fs::write(dir.path().join("pkg/sub.py"), "value = 3\n").unwrap();
let mount = || vec![MountSpec::new("/mnt", dir.path(), MountSpecMode::ReadOnly).unwrap()];
let pool = Pool::new(config()).await.unwrap();
let mut session = pool.checkout(&ReplConfig::default()).await.unwrap();
let code = "import helpers\nfrom pkg.sub import value\n(helpers.double(value), helpers.__file__)";
let mut event = session.feed(code, vec![], mount(), false, &mut no_print).await.unwrap();
let mut asked = Vec::new();
while matches!(&event, TurnEvent::OsCall { function_name, .. } if function_name == IMPORT_CALL) {
let args = expect_import_args(event);
let mut answer = ResumeValue::NotFound;
for path in args.candidates() {
if let ModuleRead::Text(source) = session.read_from_mounts(&path).await.unwrap() {
answer = ResumeValue::ModuleFile(ModuleFile { path, source });
break;
}
}
asked.push((args.module, args.search_path));
event = session.resume(answer, &mut no_print).await.unwrap();
}
let dirs = |dir: &str| vec![dir.to_owned()];
assert_eq!(
asked,
[
("helpers".to_owned(), dirs("/mnt")),
("pkg".to_owned(), dirs("/mnt")),
("pkg.sub".to_owned(), dirs("/mnt/pkg")),
]
);
assert_eq!(
expect_complete(event),
MontyObject::tuple([MontyObject::int(6), MontyObject::string("/mnt/helpers.py".to_owned())])
);
let err = session.read_from_mounts("/mnt/helpers.py").await.unwrap_err();
assert!(matches!(err, PoolError::Protocol(_)), "{err:?}");
session.finish().await.unwrap();
}
#[tokio::test]
async fn working_directory_survives_a_rejected_first_feed() {
let dir = tempfile::tempdir().unwrap();
let mount = || vec![MountSpec::new("/mnt", dir.path(), MountSpecMode::ReadOnly).unwrap()];
let pool = Pool::new(config()).await.unwrap();
let mut session = pool
.checkout(&ReplConfig {
type_check: true,
..ReplConfig::default()
})
.await
.unwrap();
let err = session
.feed("x: int = 'nope'", vec![], mount(), false, &mut no_print)
.await
.unwrap_err();
assert!(matches!(err, PoolError::Typing(_)), "expected Typing, got {err:?}");
let result = session
.feed("import os\nos.getcwd()", vec![], mount(), false, &mut no_print)
.await;
let event = feed_with_mounts(&mut session, result).await.unwrap();
assert_eq!(expect_complete(event), MontyObject::string("/mnt".to_owned()));
session.finish().await.unwrap();
}
#[tokio::test]
async fn working_directory_survives_an_oversize_first_feed() {
let dir = tempfile::tempdir().unwrap();
let mount = || vec![MountSpec::new("/mnt", dir.path(), MountSpecMode::ReadOnly).unwrap()];
let pool = Pool::new(config()).await.unwrap();
let mut session = pool.checkout(&ReplConfig::default()).await.unwrap();
let huge = MontyObject::string("x".repeat(257 * 1024 * 1024));
let err = session
.feed("data", vec![("data".to_owned(), huge)], mount(), false, &mut no_print)
.await
.unwrap_err();
assert!(matches!(err, PoolError::Runtime(_)), "expected Runtime, got {err:?}");
let result = session
.feed("import os\nos.getcwd()", vec![], mount(), false, &mut no_print)
.await;
let event = feed_with_mounts(&mut session, result).await.unwrap();
assert_eq!(expect_complete(event), MontyObject::string("/mnt".to_owned()));
session.finish().await.unwrap();
}
#[tokio::test]
async fn mounted_filesystem_ops_are_serviced_by_the_parent() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("data.txt"), "mounted!").unwrap();
let mount = || vec![MountSpec::new("/mnt", dir.path(), MountSpecMode::ReadWrite).unwrap()];
let pool = Pool::new(config()).await.unwrap();
let mut session = pool.checkout(&ReplConfig::default()).await.unwrap();
let result = session
.feed(
"from pathlib import Path\nPath('/mnt/data.txt').read_text()",
vec![],
mount(),
false,
&mut no_print,
)
.await;
let event = feed_with_mounts(&mut session, result).await.unwrap();
assert_eq!(expect_complete(event), MontyObject::string("mounted!".to_owned()));
let code = "\
Path('/mnt/sub').mkdir(parents=True, exist_ok=True)
Path('/mnt/sub/out.txt').write_text('written')
Path('/mnt/sub/out.txt').rename('/mnt/sub/renamed.txt')
with open('/mnt/sub/renamed.txt') as f:
body = f.read()
body";
let result = session.feed(code, vec![], mount(), false, &mut no_print).await;
let event = feed_with_mounts(&mut session, result).await.unwrap();
assert_eq!(expect_complete(event), MontyObject::string("written".to_owned()));
assert_eq!(
fs::read_to_string(dir.path().join("sub/renamed.txt")).unwrap(),
"written"
);
session.finish().await.unwrap();
}
#[tokio::test]
async fn a_reused_mount_spec_survives_a_rename_of_its_host_directory() {
let base = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
let shared = base.path().join("shared");
fs::create_dir(&shared).unwrap();
fs::write(shared.join("inside.txt"), "in-mount").unwrap();
fs::write(outside.path().join("secret.txt"), "HOST SECRET").unwrap();
if try_symlink_dir(outside.path(), &base.path().join("prepared-link")).is_err() {
eprintln!("skipping: this host cannot create symlinks");
return;
}
let child = MountSpec::new("/child", shared, MountSpecMode::ReadOnly).unwrap();
let parent = MountSpec::new("/parent", base.path(), MountSpecMode::ReadWrite).unwrap();
let pool = Pool::new(config()).await.unwrap();
let mut session = pool.checkout(&ReplConfig::default()).await.unwrap();
let code = "\
from pathlib import Path
Path('/parent/shared').rename('/parent/old-shared')
Path('/parent/prepared-link').rename('/parent/shared')
'swapped'";
let result = session.feed(code, vec![], vec![parent], false, &mut no_print).await;
match feed_with_mounts(&mut session, result).await {
Ok(event) => assert_eq!(expect_complete(event), MontyObject::string("swapped".to_owned())),
#[cfg(windows)]
Err(_) => return,
#[cfg(not(windows))]
Err(err) => panic!("staging the swap failed: {err:?}"),
}
let code = "\
from pathlib import Path
f\"{Path('/child/inside.txt').read_text()}:{Path('/child/secret.txt').exists()}\"";
let result = session.feed(code, vec![], vec![child], false, &mut no_print).await;
let event = feed_with_mounts(&mut session, result).await.unwrap();
assert_eq!(expect_complete(event), MontyObject::string("in-mount:False".to_owned()));
session.finish().await.unwrap();
}
#[tokio::test]
async fn read_only_and_overlay_mount_semantics() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("data.txt"), "original").unwrap();
let mount = |mode| vec![MountSpec::new("/mnt", dir.path(), mode).unwrap()];
let pool = Pool::new(config()).await.unwrap();
let mut session = pool.checkout(&ReplConfig::default()).await.unwrap();
let result = session
.feed(
"from pathlib import Path\nPath('/mnt/new.txt').write_text('x')",
vec![],
mount(MountSpecMode::ReadOnly),
false,
&mut no_print,
)
.await;
let err = feed_with_mounts(&mut session, result).await.unwrap_err();
let PoolError::Runtime(exc) = err else {
panic!("expected Runtime, got {err:?}");
};
assert_eq!(exc.exc_type().to_string(), "PermissionError");
let code = "\
from pathlib import Path
Path('/mnt/data.txt').write_text('changed')
Path('/mnt/data.txt').read_text()";
let result = session
.feed(code, vec![], mount(MountSpecMode::Overlay), false, &mut no_print)
.await;
let event = feed_with_mounts(&mut session, result).await.unwrap();
assert_eq!(expect_complete(event), MontyObject::string("changed".to_owned()));
assert_eq!(fs::read_to_string(dir.path().join("data.txt")).unwrap(), "original");
let result = session
.feed(
"Path('/mnt/data.txt').read_text()",
vec![],
mount(MountSpecMode::Overlay),
false,
&mut no_print,
)
.await;
let event = feed_with_mounts(&mut session, result).await.unwrap();
assert_eq!(expect_complete(event), MontyObject::string("original".to_owned()));
session.finish().await.unwrap();
}
#[tokio::test]
async fn mount_write_bytes_limit_is_enforced() {
let dir = tempfile::tempdir().unwrap();
let pool = Pool::new(config()).await.unwrap();
let mut session = pool.checkout(&ReplConfig::default()).await.unwrap();
let code = "\
from pathlib import Path
try:
Path('/mnt/big.txt').write_text('x' * 100)
except OSError as exc:
msg = str(exc)
msg";
let result = session
.feed(
code,
vec![],
vec![{
let mut spec = MountSpec::new("/mnt", dir.path(), MountSpecMode::ReadWrite).unwrap();
spec.write_bytes_limit = Some(10);
spec
}],
false,
&mut no_print,
)
.await;
let event = feed_with_mounts(&mut session, result).await.unwrap();
assert_eq!(
expect_complete(event),
MontyObject::string("disk write limit of 10 bytes exceeded".to_owned())
);
session.finish().await.unwrap();
}
#[tokio::test]
async fn mount_memory_usage_limit_is_enforced() {
let dir = tempfile::tempdir().unwrap();
let pool = Pool::new(config()).await.unwrap();
let mut session = pool.checkout(&ReplConfig::default()).await.unwrap();
let code = "\
from pathlib import Path
p = Path('/mnt/f.bin')
p.write_bytes(b'a' * 600)
msg = ''
try:
p.read_bytes()
except MemoryError as exc:
msg = str(exc)
msg";
let mut spec = MountSpec::new("/mnt", dir.path(), MountSpecMode::Overlay).unwrap();
spec.memory_usage_limit = 1_000;
let result = session.feed(code, vec![], vec![spec], false, &mut no_print).await;
let event = feed_with_mounts(&mut session, result).await.unwrap();
assert_eq!(
expect_complete(event),
MontyObject::string("mount memory usage limit of 1 KB exceeded".to_owned())
);
session.finish().await.unwrap();
}
#[tokio::test]
async fn uncovered_os_calls_surface_alongside_mounted_ones() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("data.txt"), "covered").unwrap();
let pool = Pool::new(config()).await.unwrap();
let mut session = pool.checkout(&ReplConfig::default()).await.unwrap();
let code = "\
from pathlib import Path
covered = Path('/mnt/data.txt').read_text()
covered + ':' + Path('/elsewhere/file.txt').read_text()";
let result = session
.feed(
code,
vec![],
vec![MountSpec::new("/mnt", dir.path(), MountSpecMode::ReadOnly).unwrap()],
false,
&mut no_print,
)
.await;
let event = feed_with_mounts(&mut session, result).await.unwrap();
let TurnEvent::OsCall {
function_name, args, ..
} = event
else {
panic!("expected OsCall, got {event:?}");
};
assert_eq!(function_name, "Path.read_text");
assert_eq!(
args,
CallArgs::from(vec![MontyObject::path("/elsewhere/file.txt".to_owned())])
);
let event = session
.resume(
ResumeValue::Return(MontyObject::string("uncovered".to_owned())),
&mut no_print,
)
.await
.unwrap();
assert_eq!(
expect_complete(event),
MontyObject::string("covered:uncovered".to_owned())
);
session.finish().await.unwrap();
}
#[tokio::test]
async fn suspended_feed_restores_with_mounts_re_supplied() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("data.txt"), "after resume").unwrap();
let mount = || vec![MountSpec::new("/mnt", dir.path(), MountSpecMode::ReadOnly).unwrap()];
let pool = Pool::new(config()).await.unwrap();
let mut session = pool.checkout(&ReplConfig::default()).await.unwrap();
let code = "\
from pathlib import Path
external = Path('/external/answer.txt').read_text()
external + ' ' + Path('/mnt/data.txt').read_text()";
let event = session.feed(code, vec![], mount(), false, &mut no_print).await.unwrap();
assert!(matches!(event, TurnEvent::OsCall { .. }), "got {event:?}");
let state = session.dump().await.unwrap();
drop(session);
let mut restored = pool.checkout(&ReplConfig::default()).await.unwrap();
let (event, _) = restored.restore(state, mount(), &mut no_print).await.unwrap();
let Some(TurnEvent::OsCall {
function_name, args, ..
}) = &event
else {
panic!("expected OsCall, got {event:?}");
};
assert_eq!(function_name, "Path.read_text");
assert_eq!(
args,
&CallArgs::from(vec![MontyObject::path("/external/answer.txt".to_owned())])
);
let result = restored
.resume(
ResumeValue::Return(MontyObject::string("hello".to_owned())),
&mut no_print,
)
.await;
let event = feed_with_mounts(&mut restored, result).await.unwrap();
assert_eq!(
expect_complete(event),
MontyObject::string("hello after resume".to_owned())
);
restored.finish().await.unwrap();
}
#[tokio::test]
async fn restored_os_call_is_serviced_by_restore_mounts() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("data.txt"), "from mount").unwrap();
let pool = Pool::new(config()).await.unwrap();
let mut session = pool.checkout(&ReplConfig::default()).await.unwrap();
let event = session
.feed(
"from pathlib import Path\nPath('/mnt/data.txt').read_text()",
vec![],
vec![],
false,
&mut no_print,
)
.await
.unwrap();
assert!(matches!(event, TurnEvent::OsCall { .. }), "got {event:?}");
let state = session.dump().await.unwrap();
drop(session);
let mut restored = pool.checkout(&ReplConfig::default()).await.unwrap();
let (event, _) = restored
.restore(
state,
vec![MountSpec::new("/mnt", dir.path(), MountSpecMode::ReadOnly).unwrap()],
&mut no_print,
)
.await
.unwrap();
let event = event.expect("suspended dump must re-announce a turn event");
assert!(matches!(event, TurnEvent::OsCall { .. }), "got {event:?}");
let event = feed_with_mounts(&mut restored, Ok(event)).await.unwrap();
assert_eq!(expect_complete(event), MontyObject::string("from mount".to_owned()));
restored.finish().await.unwrap();
}
#[tokio::test]
async fn oversize_frames_are_rejected_without_killing_the_worker() {
const OVERSIZE: usize = 257 * 1024 * 1024;
let pool = Pool::new(config()).await.unwrap();
let mut session = pool.checkout(&ReplConfig::default()).await.unwrap();
let huge = MontyObject::string("x".repeat(OVERSIZE));
let err = session
.feed("data", vec![("data".to_owned(), huge)], vec![], false, &mut no_print)
.await
.unwrap_err();
let PoolError::Runtime(exc) = err else {
panic!("expected Runtime for oversize input, got {err:?}");
};
assert!(
exc.message()
.is_some_and(|m| m.contains("request frame") && m.contains("exceeds the maximum")),
"unexpected message: {:?}",
exc.message()
);
let event = session
.feed("1 + 1", vec![], vec![], false, &mut no_print)
.await
.unwrap();
assert_eq!(expect_complete(event), MontyObject::int(2));
let err = session
.feed(&format!("'x' * {OVERSIZE}"), vec![], vec![], false, &mut no_print)
.await
.unwrap_err();
let PoolError::Runtime(exc) = err else {
panic!("expected Runtime for oversize result, got {err:?}");
};
assert!(
exc.message()
.is_some_and(|m| m.contains("result frame") && m.contains("exceeds the maximum")),
"unexpected message: {:?}",
exc.message()
);
let event = session
.feed("1 + 1", vec![], vec![], false, &mut no_print)
.await
.unwrap();
assert_eq!(expect_complete(event), MontyObject::int(2));
let err = session
.feed(
&format!(
"result = 'not caught'\ntry:\n fetch('x' * {OVERSIZE})\nexcept RuntimeError:\n result = 'caught'\nresult"
),
vec![],
vec![],
false,
&mut no_print,
)
.await
.unwrap_err();
let PoolError::Runtime(exc) = err else {
panic!("expected Runtime for oversize external-call args, got {err:?}");
};
assert!(
exc.message()
.is_some_and(|m| m.contains("argument frame") && m.contains("exceeds the maximum")),
"unexpected message: {:?}",
exc.message()
);
let event = session
.feed("1 + 1", vec![], vec![], false, &mut no_print)
.await
.unwrap();
assert_eq!(expect_complete(event), MontyObject::int(2));
session.finish().await.unwrap();
let mut session = pool.checkout(&ReplConfig::default()).await.unwrap();
let event = session
.feed("len(grab())", vec![], vec![], false, &mut no_print)
.await
.unwrap();
assert!(matches!(event, TurnEvent::FunctionCall { .. }), "got {event:?}");
let huge = MontyObject::string("x".repeat(OVERSIZE));
let err = session
.resume(ResumeValue::Return(huge), &mut no_print)
.await
.unwrap_err();
let PoolError::Runtime(exc) = err else {
panic!("expected Runtime for oversize resume value, got {err:?}");
};
assert!(
exc.message()
.is_some_and(|m| m.contains("request frame") && m.contains("exceeds the maximum")),
"unexpected message: {:?}",
exc.message()
);
let event = session
.resume(
ResumeValue::Return(MontyObject::string("small".to_owned())),
&mut no_print,
)
.await
.unwrap();
assert_eq!(expect_complete(event), MontyObject::int(5));
let event = session
.feed("missing", vec![], vec![], false, &mut no_print)
.await
.unwrap();
assert!(matches!(event, TurnEvent::NameLookup { ref name, .. } if name == "missing"));
let huge = MontyObject::string("x".repeat(OVERSIZE));
let err = session.resume_name_lookup(Some(huge), &mut no_print).await.unwrap_err();
let PoolError::Runtime(exc) = err else {
panic!("expected Runtime for oversize name-lookup value, got {err:?}");
};
assert!(
exc.message()
.is_some_and(|m| m.contains("request frame") && m.contains("exceeds the maximum")),
"unexpected message: {:?}",
exc.message()
);
let event = session
.resume_name_lookup(Some(MontyObject::string("small".to_owned())), &mut no_print)
.await
.unwrap();
assert_eq!(expect_complete(event), MontyObject::string("small".to_owned()));
let code = "import asyncio\nasync def main():\n return await go()\nasyncio.run(main())";
let event = session.feed(code, vec![], vec![], false, &mut no_print).await.unwrap();
let TurnEvent::FunctionCall { call_id, .. } = event else {
panic!("expected FunctionCall, got {event:?}");
};
let event = session.resume(ResumeValue::Future, &mut no_print).await.unwrap();
assert!(matches!(event, TurnEvent::ResolveFutures { .. }), "got {event:?}");
let huge = MontyObject::string("x".repeat(OVERSIZE));
let err = session
.resume_futures(vec![(call_id, ResumeValue::Return(huge))], &mut no_print)
.await
.unwrap_err();
let PoolError::Runtime(exc) = err else {
panic!("expected Runtime for oversize future result, got {err:?}");
};
assert!(
exc.message()
.is_some_and(|m| m.contains("request frame") && m.contains("exceeds the maximum")),
"unexpected message: {:?}",
exc.message()
);
let event = session
.resume_futures(
vec![(call_id, ResumeValue::Return(MontyObject::int(99)))],
&mut no_print,
)
.await
.unwrap();
assert_eq!(expect_complete(event), MontyObject::int(99));
session.finish().await.unwrap();
}
#[tokio::test]
async fn oversize_dump_while_suspended_fails_cleanly_and_resumes() {
let pool = Pool::new(config()).await.unwrap();
let mut session = pool.checkout(&ReplConfig::default()).await.unwrap();
let code = "data = 'x' * (300 * 1024 * 1024)\nf()\nlen(data)";
let event = session.feed(code, vec![], vec![], false, &mut no_print).await.unwrap();
assert!(matches!(event, TurnEvent::FunctionCall { .. }), "got {event:?}");
let err = session.dump().await.unwrap_err();
let PoolError::Runtime(exc) = err else {
panic!("expected Runtime for oversize dump, got {err:?}");
};
assert!(
exc.message()
.is_some_and(|m| m.contains("result frame") && m.contains("exceeds the maximum")),
"unexpected message: {:?}",
exc.message()
);
let event = session
.resume(ResumeValue::Return(MontyObject::none()), &mut no_print)
.await
.unwrap();
assert_eq!(expect_complete(event), MontyObject::int(300 * 1024 * 1024));
session.finish().await.unwrap();
}
#[tokio::test]
async fn inputs_and_prints() {
let pool = Pool::new(config()).await.unwrap();
let mut session = pool.checkout(&ReplConfig::default()).await.unwrap();
let mut output = String::new();
let event = session
.feed(
"print('hello', name)\nlen(name)",
vec![("name".to_owned(), MontyObject::string("monty".to_owned()))],
vec![],
false,
&mut on_print_sync(|_, text: &str| output.push_str(text)),
)
.await
.unwrap();
assert_eq!(expect_complete(event), MontyObject::int(5));
assert_eq!(output, "hello monty\n");
session.finish().await.unwrap();
}
#[tokio::test]
async fn prints_are_batched_into_few_events() {
let pool = Pool::new(config()).await.unwrap();
let mut session = pool.checkout(&ReplConfig::default()).await.unwrap();
let mut output = String::new();
let mut events = 0;
let event = session
.feed(
"for i in range(2000):\n print(i)",
vec![],
vec![],
false,
&mut on_print_sync(|_, text: &str| {
events += 1;
output.push_str(text);
}),
)
.await
.unwrap();
assert_eq!(expect_complete(event), MontyObject::none());
let mut expected = String::new();
for i in 0..2000 {
writeln!(expected, "{i}").unwrap();
}
assert_eq!(output, expected);
assert!(
events < 200,
"expected far fewer than one event per print, got {events}"
);
session.finish().await.unwrap();
}
#[tokio::test]
async fn one_huge_write_is_split_at_the_size_threshold() {
const FLUSH_BYTES: usize = 8 * 1024;
let pool = Pool::new(config()).await.unwrap();
let repl = ReplConfig {
print_flush_interval: Some(Duration::from_secs(600)),
..ReplConfig::default()
};
let mut session = pool.checkout(&repl).await.unwrap();
let mut chunks: Vec<usize> = Vec::new();
let mut total = 0;
session
.feed(
"print('x' * 100_000, end='')",
vec![],
vec![],
false,
&mut on_print_sync(|_, text: &str| {
chunks.push(text.len());
total += text.len();
}),
)
.await
.unwrap();
assert_eq!(total, 100_000);
assert!(chunks.len() > 1, "expected the write to be split, got {chunks:?}");
assert!(
chunks.iter().all(|len| *len <= FLUSH_BYTES),
"every chunk must stay within the threshold, got {chunks:?}"
);
session.finish().await.unwrap();
}
#[tokio::test]
async fn zero_flush_interval_delivers_one_event_per_line() {
let pool = Pool::new(config()).await.unwrap();
let repl = ReplConfig {
print_flush_interval: Some(Duration::ZERO),
..ReplConfig::default()
};
let mut session = pool.checkout(&repl).await.unwrap();
let mut lines = Vec::new();
session
.feed(
"for i in range(20):\n print(i)",
vec![],
vec![],
false,
&mut on_print_sync(|_, text: &str| lines.push(text.to_owned())),
)
.await
.unwrap();
assert_eq!(lines.len(), 20);
assert_eq!(lines[0], "0\n");
assert_eq!(lines[19], "19\n");
session.finish().await.unwrap();
}
#[tokio::test]
async fn zero_flush_interval_splits_embedded_newlines() {
let pool = Pool::new(config()).await.unwrap();
let repl = ReplConfig {
print_flush_interval: Some(Duration::ZERO),
..ReplConfig::default()
};
let mut session = pool.checkout(&repl).await.unwrap();
let mut events = Vec::new();
session
.feed(
r#"print("a\nb")"#,
vec![],
vec![],
false,
&mut on_print_sync(|_, text: &str| events.push(text.to_owned())),
)
.await
.unwrap();
assert_eq!(events, vec!["a\n".to_owned(), "b\n".to_owned()]);
session.finish().await.unwrap();
}
#[tokio::test]
async fn silence_releases_buffered_output() {
let pool = Pool::new(config()).await.unwrap();
let repl = ReplConfig {
print_flush_interval: Some(Duration::from_millis(1)),
..ReplConfig::default()
};
let mut session = pool.checkout(&repl).await.unwrap();
let mut events: Vec<String> = Vec::new();
session
.feed(
"print('first')\nt = 0\nfor i in range(200_000):\n t += i\nprint('second')",
vec![],
vec![],
false,
&mut on_print_sync(|_, text: &str| events.push(text.to_owned())),
)
.await
.unwrap();
assert_eq!(events.first().map(String::as_str), Some("first\n"));
assert_eq!(events.concat(), "first\nsecond\n");
session.finish().await.unwrap();
}
#[tokio::test]
async fn buffered_output_is_flushed_before_a_suspension() {
let pool = Pool::new(config()).await.unwrap();
let mut session = pool.checkout(&ReplConfig::default()).await.unwrap();
let mut output = String::new();
let event = session
.feed(
"print('before')\nask()",
vec![],
vec![],
false,
&mut on_print_sync(|_, text: &str| output.push_str(text)),
)
.await
.unwrap();
assert!(
matches!(event, TurnEvent::FunctionCall { ref function_name, .. } if function_name == "ask"),
"expected a call suspension, got {event:?}"
);
assert_eq!(output, "before\n");
session.finish().await.unwrap();
}
#[tokio::test]
async fn external_function_round_trip() {
let pool = Pool::new(config()).await.unwrap();
let mut session = pool.checkout(&ReplConfig::default()).await.unwrap();
let event = session
.feed("fetch('https://x') + '!'", vec![], vec![], false, &mut no_print)
.await
.unwrap();
let TurnEvent::FunctionCall {
function_name, args, ..
} = event
else {
panic!("expected FunctionCall, got {event:?}");
};
assert_eq!(function_name, "fetch");
assert_eq!(args, CallArgs::from(vec![MontyObject::string("https://x".to_owned())]));
let event = session
.resume(
ResumeValue::Return(MontyObject::string("body".to_owned())),
&mut no_print,
)
.await
.unwrap();
assert_eq!(expect_complete(event), MontyObject::string("body!".to_owned()));
session.finish().await.unwrap();
}
#[tokio::test]
async fn runtime_error_keeps_session_and_worker() {
let pool = Pool::new(config()).await.unwrap();
let mut session = pool.checkout(&ReplConfig::default()).await.unwrap();
assert_eq!(
expect_complete(
session
.feed("kept = 1", vec![], vec![], false, &mut no_print)
.await
.unwrap()
),
MontyObject::none()
);
let err = session
.feed("1 / 0", vec![], vec![], false, &mut no_print)
.await
.unwrap_err();
let PoolError::Runtime(exc) = err else {
panic!("expected Runtime, got {err:?}");
};
assert_eq!(exc.message(), Some("division by zero"));
assert_eq!(
expect_complete(
session
.feed("kept + 41", vec![], vec![], false, &mut no_print)
.await
.unwrap()
),
MontyObject::int(42)
);
session.finish().await.unwrap();
assert_eq!(pool.idle_workers(), 1);
}
#[tokio::test]
async fn sigkill_mid_request_is_a_clean_crash_and_the_pool_recovers() {
let pool = Pool::new(config()).await.unwrap();
let mut session = pool.checkout(&ReplConfig::default()).await.unwrap();
let pid = session.pid().unwrap();
let killer = thread::spawn(move || {
thread::sleep(Duration::from_millis(300));
kill_pid(pid);
});
let err = session
.feed("while True:\n pass", vec![], vec![], false, &mut no_print)
.await
.unwrap_err();
killer.join().unwrap();
assert!(matches!(err, PoolError::Crashed { .. }), "got {err:?}");
let mut session = pool.checkout(&ReplConfig::default()).await.unwrap();
assert_ne!(session.pid().unwrap(), pid);
assert_eq!(
expect_complete(
session
.feed("1 + 1", vec![], vec![], false, &mut no_print)
.await
.unwrap()
),
MontyObject::int(2)
);
session.finish().await.unwrap();
}
#[tokio::test]
async fn worker_killed_while_idle_is_replaced_transparently() {
let pool = Pool::new(config()).await.unwrap();
let pids = pool.idle_worker_pids();
assert_eq!(pids.len(), 1);
kill_pid(pids[0]);
sleep(Duration::from_millis(100)).await;
let mut session = pool.checkout(&ReplConfig::default()).await.unwrap();
assert_eq!(
expect_complete(
session
.feed("2 + 2", vec![], vec![], false, &mut no_print)
.await
.unwrap()
),
MontyObject::int(4)
);
session.finish().await.unwrap();
}
#[tokio::test]
async fn hard_child_crash_does_not_harm_the_pool() {
let pool = Pool::new(config()).await.unwrap();
let mut session = pool.checkout(&ReplConfig::default()).await.unwrap();
let mut code = String::with_capacity(300_002);
code.push('a');
for _ in 0..150_000 {
code.push_str(".x");
}
let err = session
.feed(&code, vec![], vec![], false, &mut no_print)
.await
.unwrap_err();
assert!(
matches!(err, PoolError::Crashed { .. } | PoolError::Runtime(_)),
"got {err:?}"
);
if let PoolError::Runtime(exc) = &err {
assert_ne!(
exc.message(),
Some("the worker exceeded its memory limit and was terminated")
);
}
let mut session = pool.checkout(&ReplConfig::default()).await.unwrap();
assert_eq!(
expect_complete(
session
.feed("1 + 1", vec![], vec![], false, &mut no_print)
.await
.unwrap()
),
MontyObject::int(2)
);
session.finish().await.unwrap();
}
#[tokio::test]
async fn deadline_kills_hung_worker_after_request_timeout() {
let mut config = config();
config.request_timeout = Some(Duration::from_secs(1));
let pool = Pool::new(config).await.unwrap();
let mut session = loop {
match pool.checkout(&ReplConfig::default()).await {
Ok(session) => break session,
Err(PoolError::Timeout { .. }) => {}
Err(err) => panic!("checkout failed: {err:?}"),
}
};
let err = session
.feed("while True:\n pass", vec![], vec![], false, &mut no_print)
.await
.unwrap_err();
assert!(matches!(err, PoolError::Timeout { .. }), "got {err:?}");
let mut session = pool.checkout(&ReplConfig::default()).await.unwrap();
assert_eq!(
expect_complete(
session
.feed("3 + 3", vec![], vec![], false, &mut no_print)
.await
.unwrap()
),
MontyObject::int(6)
);
session.finish().await.unwrap();
}
#[tokio::test]
async fn child_resource_limits_do_not_kill_the_worker() {
let pool = Pool::new(config()).await.unwrap();
let mut session = pool
.checkout(&ReplConfig {
limits: Some(ResourceLimits::default().max_feed_duration(Duration::from_millis(100))),
..ReplConfig::default()
})
.await
.unwrap();
let err = session
.feed("while True:\n pass", vec![], vec![], false, &mut no_print)
.await
.unwrap_err();
let PoolError::Runtime(exc) = err else {
panic!("expected Runtime, got {err:?}");
};
assert_eq!(exc.exc_type().to_string(), "TimeoutError");
session.finish().await.unwrap();
assert_eq!(pool.idle_workers(), 1);
}
#[tokio::test]
async fn max_memory_leaves_normal_work_alone() {
let pool = Pool::new(config()).await.unwrap();
let repl_config = ReplConfig {
limits: Some(ResourceLimits::default().max_memory(1024 * 1024)),
..ReplConfig::default()
};
let mut session = pool.checkout(&repl_config).await.unwrap();
assert_eq!(
expect_complete(
session
.feed("1 + 1", vec![], vec![], false, &mut no_print)
.await
.unwrap()
),
MontyObject::int(2)
);
session.finish().await.unwrap();
}
#[cfg(unix)]
#[tokio::test]
async fn unrecognised_exit_code_stays_an_opaque_death() {
let dir = tempfile::tempdir().unwrap();
let fake = write_fake_monty(dir.path(), "#!/bin/sh\nsleep 0.2\nexit 64\n");
let pool = Pool::new(PoolConfig::subprocess(&fake)).await.unwrap();
let err = match pool.checkout(&ReplConfig::default()).await {
Ok(mut session) => session
.feed("1 + 1", vec![], vec![], false, &mut no_print)
.await
.expect_err("expected the stand-in binary to fail the turn"),
Err(err) => err,
};
let PoolError::Crashed { cause, .. } = &err else {
panic!("expected Crashed, got {err:?}");
};
assert!(
matches!(cause, monty_pool::CrashCause::Vanished { .. }),
"an unrecognised exit code must not be classified, got {cause:?}"
);
assert_snapshot!(err.to_string(), @"monty worker crashed while waiting for a reply (exit status: 64)");
}
#[cfg(unix)]
#[tokio::test]
async fn a_subprocess_shutdown_dump_is_refused_on_the_raw_path() {
let dir = tempfile::tempdir().unwrap();
let mut replies = framed(&child_event(pb::child_event::Kind::Ok(pb::Ok {})));
replies.extend(framed(&child_event(pb::child_event::Kind::Shutdown(
pb::ShutdownDump {
dump: Some(b"a dump the child minted itself".to_vec().into()),
},
))));
let replies_path = dir.path().join("replies.bin");
fs::write(&replies_path, &replies).unwrap();
let fake = write_fake_monty(
dir.path(),
&format!("#!/bin/sh\ncat '{}'\nsleep 5\n", replies_path.display()),
);
let pool = Pool::new(PoolConfig::subprocess(&fake)).await.unwrap();
let mut checkout = pool
.checkout(&ReplConfig::default())
.await
.expect("the stand-in answers Configure with Ok");
let request = pb::ParentRequest {
kind: Some(pb::parent_request::Kind::Feed(pb::Feed {
code: "1 + 1".to_owned(),
inputs: vec![].into(),
values: None,
skip_type_check: false,
cwd: "/".to_owned(),
})),
..pb::ParentRequest::default()
};
let mut on_event = |_: &pb::ChildEvent| Box::pin(ready(())) as PrintFuture;
let err = checkout
.turn_raw(&request, &mut on_event)
.await
.expect_err("a subprocess ShutdownDump must not be accepted");
assert_snapshot!(
err.to_string(),
@"monty worker protocol error: subprocess worker sent a ShutdownDump"
);
}
#[cfg(unix)]
#[tokio::test]
async fn an_event_with_no_kind_is_refused_on_the_raw_path() {
let dir = tempfile::tempdir().unwrap();
let mut replies = framed(&child_event(pb::child_event::Kind::Ok(pb::Ok {})));
replies.extend(framed(&pb::ChildEvent::default()));
let replies_path = dir.path().join("replies.bin");
fs::write(&replies_path, &replies).unwrap();
let fake = write_fake_monty(
dir.path(),
&format!("#!/bin/sh\ncat '{}'\nsleep 5\n", replies_path.display()),
);
let pool = Pool::new(PoolConfig::subprocess(&fake)).await.unwrap();
let mut checkout = pool
.checkout(&ReplConfig::default())
.await
.expect("the stand-in answers Configure with Ok");
let request = pb::ParentRequest {
kind: Some(pb::parent_request::Kind::Feed(pb::Feed {
code: "1 + 1".to_owned(),
inputs: vec![].into(),
values: None,
skip_type_check: false,
cwd: "/".to_owned(),
})),
..pb::ParentRequest::default()
};
let mut on_event = |_: &pb::ChildEvent| Box::pin(ready(())) as PrintFuture;
let err = checkout
.turn_raw(&request, &mut on_event)
.await
.expect_err("a kindless event must not be accepted as a turn-ender");
assert_snapshot!(
err.to_string(),
@"monty worker protocol error: worker sent an event with no kind"
);
}
#[cfg(unix)]
#[tokio::test]
async fn a_fatal_error_on_the_raw_path_discards_the_worker() {
let dir = tempfile::tempdir().unwrap();
let mut replies = framed(&child_event(pb::child_event::Kind::Ok(pb::Ok {})));
replies.extend(framed(&child_event(pb::child_event::Kind::FatalError(
pb::FatalError {
message: "the child is done".to_owned(),
},
))));
let replies_path = dir.path().join("replies.bin");
fs::write(&replies_path, &replies).unwrap();
let fake = write_fake_monty(
dir.path(),
&format!("#!/bin/sh\ncat '{}'\nsleep 5\n", replies_path.display()),
);
let pool = Pool::new(PoolConfig::subprocess(&fake)).await.unwrap();
let mut checkout = pool
.checkout(&ReplConfig::default())
.await
.expect("the stand-in answers Configure with Ok");
let request = pb::ParentRequest {
kind: Some(pb::parent_request::Kind::Feed(pb::Feed {
code: "1 + 1".to_owned(),
inputs: vec![].into(),
values: None,
skip_type_check: false,
cwd: "/".to_owned(),
})),
..pb::ParentRequest::default()
};
let mut on_event = |_: &pb::ChildEvent| Box::pin(ready(())) as PrintFuture;
let event = checkout
.turn_raw(&request, &mut on_event)
.await
.expect("the fatal frame is the turn-ender");
let Some(pb::child_event::Kind::FatalError(fatal)) = event.kind else {
panic!("expected the FatalError frame back, got {:?}", event.kind);
};
assert_eq!(fatal.message, "the child is done");
let err = checkout.turn_raw(&request, &mut on_event).await.unwrap_err();
assert!(matches!(err, PoolError::Finished), "got {err:?}");
}
#[cfg(unix)]
fn framed(event: &pb::ChildEvent) -> Vec<u8> {
let mut buf = Vec::new();
encode_framed_into(event, &mut buf).expect("encode");
buf
}
#[cfg(unix)]
fn child_event(kind: pb::child_event::Kind) -> pb::ChildEvent {
pb::ChildEvent {
kind: Some(kind),
..pb::ChildEvent::default()
}
}
#[tokio::test]
async fn refused_allocation_is_a_memory_error_and_the_pool_recovers() {
let pool = Pool::new(config()).await.unwrap();
let mut session = pool.checkout(&ReplConfig::default()).await.unwrap();
let err = session
.feed("x = ' ' * (1 << 60)", vec![], vec![], false, &mut no_print)
.await
.unwrap_err();
let PoolError::Runtime(exc) = err else {
panic!("expected Runtime, got {err:?}");
};
assert_eq!(exc.exc_type().to_string(), "MemoryError");
assert_eq!(
exc.message(),
Some("the worker exceeded its memory limit and was terminated")
);
assert!(matches!(session.finish().await, Err(PoolError::Finished)));
let mut session = pool.checkout(&ReplConfig::default()).await.unwrap();
assert_eq!(
expect_complete(
session
.feed("3 + 3", vec![], vec![], false, &mut no_print)
.await
.unwrap()
),
MontyObject::int(6)
);
session.finish().await.unwrap();
}
#[cfg(unix)]
#[tokio::test]
async fn special_files_in_mounts_are_rejected_without_blocking() {
let dir = tempfile::tempdir().unwrap();
let status = Command::new("mkfifo").arg(dir.path().join("pipe")).status().unwrap();
assert!(status.success(), "mkfifo failed");
let pool = Pool::new(config()).await.unwrap();
let mut session = pool.checkout(&ReplConfig::default()).await.unwrap();
let result = session
.feed(
"from pathlib import Path\nPath('/mnt/pipe').read_text()",
vec![],
vec![MountSpec::new("/mnt", dir.path(), MountSpecMode::ReadOnly).unwrap()],
false,
&mut no_print,
)
.await;
let err = feed_with_mounts(&mut session, result).await.unwrap_err();
let PoolError::Runtime(exc) = err else {
panic!("expected Runtime, got {err:?}");
};
assert_eq!(exc.exc_type().to_string(), "PermissionError");
session.finish().await.unwrap();
}
#[tokio::test]
async fn suspension_time_does_not_consume_the_duration_budget() {
let pool = Pool::new(config()).await.unwrap();
let mut session = pool
.checkout(&ReplConfig {
limits: Some(ResourceLimits::default().max_feed_duration(Duration::from_millis(300))),
..ReplConfig::default()
})
.await
.unwrap();
let event = session
.feed("fetch('https://x') + '!'", vec![], vec![], false, &mut no_print)
.await
.unwrap();
assert!(matches!(event, TurnEvent::FunctionCall { .. }));
sleep(Duration::from_millis(600)).await;
let event = session
.resume(
ResumeValue::Return(MontyObject::string("body".to_owned())),
&mut no_print,
)
.await
.unwrap();
assert_eq!(expect_complete(event), MontyObject::string("body!".to_owned()));
session.finish().await.unwrap();
}
#[tokio::test]
async fn os_policy_are_answered_in_the_worker() {
let pool = Pool::new(config()).await.unwrap();
let mut session = pool
.checkout(&ReplConfig {
os_policy: OsPolicy {
sleep: SleepMode::System(Duration::from_millis(10)),
..OsPolicy::default()
},
..ReplConfig::default()
})
.await
.unwrap();
let code = "import asyncio, random, time\nfrom datetime import date\nt = time.time()\ntime.sleep(3600)\n\
(date.today().year >= 2026, time.time() >= t + 0.01, \
asyncio.run(asyncio.sleep(3600, 'woken')), 0 <= random.random() < 1)";
let mut event = session.feed(code, vec![], vec![], false, &mut no_print).await.unwrap();
let mut slept = vec![];
while let TurnEvent::OsCall {
function_name,
system_sleep,
..
} = &event
{
let delay = system_sleep.expect("only the sleeps reach the caller");
slept.push((function_name.clone(), delay));
sleep(delay).await;
event = session
.resume(ResumeValue::Return(MontyObject::none()), &mut no_print)
.await
.unwrap();
}
assert_eq!(
slept,
vec![
("system.sleep".to_owned(), Duration::from_millis(10)),
("system.async_sleep".to_owned(), Duration::from_millis(10)),
]
);
assert_eq!(
expect_complete(event),
MontyObject::tuple([
MontyObject::bool(true),
MontyObject::bool(true),
MontyObject::string("woken"),
MontyObject::bool(true),
])
);
session.finish().await.unwrap();
let mut session = pool
.checkout(&ReplConfig {
os_policy: OsPolicy {
datetime: DateTimeSource::Fixed {
unix_seconds: 1_700_000_000,
microsecond: 0,
},
sleep: SleepMode::Zero,
random_start: RandomStart::Seed(RandomSeed::Int(42.into())),
..OsPolicy::default()
},
..ReplConfig::default()
})
.await
.unwrap();
let code = "import random, time\ntime.sleep(3600)\n(time.time(), random.random())";
let event = session.feed(code, vec![], vec![], false, &mut no_print).await.unwrap();
assert_eq!(
expect_complete(event),
MontyObject::tuple([
MontyObject::float(1_700_000_000.0),
MontyObject::float(0.639_426_798_457_883_7),
])
);
session.finish().await.unwrap();
}
fn fixed_clock(unix_seconds: i64) -> ReplConfig {
ReplConfig {
os_policy: OsPolicy {
datetime: DateTimeSource::Fixed {
unix_seconds,
microsecond: 0,
},
..OsPolicy::default()
},
..ReplConfig::default()
}
}
#[tokio::test]
async fn restored_session_takes_the_checkouts_os_policy() {
let pool = Pool::new(config()).await.unwrap();
let mut session = pool.checkout(&fixed_clock(1_000)).await.unwrap();
let event = session
.feed("import time\ntime.time()", vec![], vec![], false, &mut no_print)
.await
.unwrap();
assert_eq!(expect_complete(event), MontyObject::float(1_000.0));
let idle = session.dump().await.unwrap();
let event = session
.feed("fetch('x')\ntime.time()", vec![], vec![], false, &mut no_print)
.await
.unwrap();
assert!(matches!(event, TurnEvent::FunctionCall { .. }), "got {event:?}");
let suspended = session.dump().await.unwrap();
drop(session);
let mut restored = pool.checkout(&fixed_clock(2_000)).await.unwrap();
let (event, _script_name) = restored.restore(idle, vec![], &mut no_print).await.unwrap();
assert!(event.is_none(), "idle dump should restore without a suspension");
let event = restored
.feed("time.time()", vec![], vec![], false, &mut no_print)
.await
.unwrap();
assert_eq!(expect_complete(event), MontyObject::float(2_000.0));
restored.finish().await.unwrap();
let mut restored = pool.checkout(&fixed_clock(3_000)).await.unwrap();
let (event, _script_name) = restored.restore(suspended, vec![], &mut no_print).await.unwrap();
assert!(matches!(event, Some(TurnEvent::FunctionCall { .. })), "got {event:?}");
let event = restored
.resume(ResumeValue::Return(MontyObject::none()), &mut no_print)
.await
.unwrap();
assert_eq!(expect_complete(event), MontyObject::float(3_000.0));
restored.finish().await.unwrap();
let (event, mut restored) = restore_under_new_clock(&pool, "import os\nos.getenv('X')\ntime.time()").await;
assert!(matches!(event, Some(TurnEvent::OsCall { .. })), "got {event:?}");
let event = restored
.resume(ResumeValue::Return(MontyObject::none()), &mut no_print)
.await
.unwrap();
assert_eq!(expect_complete(event), MontyObject::float(4_000.0));
restored.finish().await.unwrap();
let (event, mut restored) = restore_under_new_clock(&pool, "missing\ntime.time()").await;
assert!(matches!(event, Some(TurnEvent::NameLookup { .. })), "got {event:?}");
let event = restored
.resume_name_lookup(Some(MontyObject::none()), &mut no_print)
.await
.unwrap();
assert_eq!(expect_complete(event), MontyObject::float(4_000.0));
restored.finish().await.unwrap();
let code = "import asyncio\nasync def main():\n await go()\n return time.time()\nasyncio.run(main())";
let (event, mut restored) = restore_under_new_clock(&pool, code).await;
let Some(TurnEvent::ResolveFutures { pending_call_ids, .. }) = event else {
panic!("expected ResolveFutures, got {event:?}");
};
let results = pending_call_ids
.into_iter()
.map(|id| (id, ResumeValue::Return(MontyObject::none())))
.collect();
let event = restored.resume_futures(results, &mut no_print).await.unwrap();
assert_eq!(expect_complete(event), MontyObject::float(4_000.0));
restored.finish().await.unwrap();
}
async fn restore_under_new_clock(pool: &Pool, code: &str) -> (Option<TurnEvent>, Checkout) {
let mut session = pool.checkout(&fixed_clock(1_000)).await.unwrap();
session
.feed("import time", vec![], vec![], false, &mut no_print)
.await
.unwrap();
let mut event = session.feed(code, vec![], vec![], false, &mut no_print).await.unwrap();
if matches!(event, TurnEvent::FunctionCall { .. }) {
event = session.resume(ResumeValue::Future, &mut no_print).await.unwrap();
}
assert!(
!matches!(event, TurnEvent::Complete(_)),
"expected a suspension, got {event:?}"
);
let state = session.dump().await.unwrap();
drop(session);
let mut restored = pool.checkout(&fixed_clock(4_000)).await.unwrap();
let (event, _script_name) = restored.restore(state, vec![], &mut no_print).await.unwrap();
(event, restored)
}
#[tokio::test]
async fn call_host_delivers_clock_and_sleeps_as_os_calls() {
let pool = Pool::new(config()).await.unwrap();
let mut session = pool
.checkout(&ReplConfig {
os_policy: OsPolicy {
datetime: DateTimeSource::CallHost,
sleep: SleepMode::CallHost,
..OsPolicy::default()
},
..ReplConfig::default()
})
.await
.unwrap();
let code = "import time
time.sleep(1.5)
time.time()";
let event = session.feed(code, vec![], vec![], false, &mut no_print).await.unwrap();
let TurnEvent::OsCall {
function_name, args, ..
} = event
else {
panic!("expected OsCall, got {event:?}");
};
assert_eq!(function_name, "time.sleep");
assert_eq!(args, CallArgs::from(vec![MontyObject::float(1.5)]));
let event = session
.resume(ResumeValue::Return(MontyObject::none()), &mut no_print)
.await
.unwrap();
let TurnEvent::OsCall { function_name, .. } = event else {
panic!("expected OsCall, got {event:?}");
};
assert_eq!(function_name, "time.time");
let event = session
.resume(ResumeValue::Return(MontyObject::float(7.5)), &mut no_print)
.await
.unwrap();
assert_eq!(expect_complete(event), MontyObject::float(7.5));
session.finish().await.unwrap();
}
#[tokio::test]
async fn suspension_limit_aborts_the_feed() {
let pool = Pool::new(config()).await.unwrap();
let mut session = pool
.checkout(&ReplConfig {
limits: Some(ResourceLimits::default().max_suspensions(3)),
..ReplConfig::default()
})
.await
.unwrap();
let code = "n = 0\nwhile True:\n try:\n open('/etc/passwd')\n except Exception:\n n += 1";
let mut event = session.feed(code, vec![], vec![], false, &mut no_print).await.unwrap();
for _ in 0..2 {
assert!(matches!(event, TurnEvent::OsCall { .. }), "got {event:?}");
event = session.resume(ResumeValue::NotHandled, &mut no_print).await.unwrap();
}
assert!(matches!(event, TurnEvent::OsCall { .. }), "got {event:?}");
let err = session
.resume(ResumeValue::NotHandled, &mut no_print)
.await
.unwrap_err();
let PoolError::Runtime(exc) = err else {
panic!("expected Runtime, got {err:?}");
};
assert_eq!(exc.exc_type().to_string(), "RuntimeError");
assert_eq!(exc.message(), Some("suspension limit 3 exceeded"));
let event = session.feed("n", vec![], vec![], false, &mut no_print).await.unwrap();
assert_eq!(expect_complete(event), MontyObject::int(3));
let err = session
.feed("fetch('x')", vec![], vec![], false, &mut no_print)
.await
.unwrap_err();
let PoolError::Runtime(exc) = err else {
panic!("expected Runtime, got {err:?}");
};
assert_eq!(exc.message(), Some("suspension limit 3 exceeded"));
session.finish().await.unwrap();
}
#[tokio::test]
async fn restored_session_readopts_its_suspension_limit() {
let pool = Pool::new(config()).await.unwrap();
let mut session = pool
.checkout(&ReplConfig {
limits: Some(ResourceLimits::default().max_suspensions(1)),
..ReplConfig::default()
})
.await
.unwrap();
let event = session
.feed("fetch('x')", vec![], vec![], false, &mut no_print)
.await
.unwrap();
assert!(matches!(event, TurnEvent::FunctionCall { .. }));
let state = session.dump().await.unwrap();
drop(session);
let mut restored = pool.checkout(&ReplConfig::default()).await.unwrap();
let (event, _script_name) = restored.restore(state, vec![], &mut no_print).await.unwrap();
assert!(matches!(event, Some(TurnEvent::FunctionCall { .. })), "got {event:?}");
let event = restored
.resume(ResumeValue::Return(MontyObject::int(1)), &mut no_print)
.await
.unwrap();
assert_eq!(expect_complete(event), MontyObject::int(1));
let err = restored
.feed("fetch('y')", vec![], vec![], false, &mut no_print)
.await
.unwrap_err();
let PoolError::Runtime(exc) = err else {
panic!("expected Runtime, got {err:?}");
};
assert_eq!(exc.message(), Some("suspension limit 1 exceeded"));
restored.finish().await.unwrap();
}
#[tokio::test]
async fn loaded_session_keeps_its_duration_budget() {
let pool = Pool::new(config()).await.unwrap();
let mut session = pool
.checkout(&ReplConfig {
limits: Some(ResourceLimits::default().max_feed_duration(Duration::from_millis(100))),
..ReplConfig::default()
})
.await
.unwrap();
let state = session.dump().await.unwrap();
drop(session);
let mut restored = pool.checkout(&ReplConfig::default()).await.unwrap();
let (event, _script_name) = restored.restore(state, vec![], &mut no_print).await.unwrap();
assert!(event.is_none(), "idle dump should restore without a suspension");
let err = restored
.feed("while True:\n pass", vec![], vec![], false, &mut no_print)
.await
.unwrap_err();
let PoolError::Runtime(exc) = err else {
panic!("expected Runtime, got {err:?}");
};
assert_eq!(exc.exc_type().to_string(), "TimeoutError");
restored.finish().await.unwrap();
}
#[tokio::test]
async fn restored_session_cannot_raise_the_destination_memory_budget() {
let pool = Pool::new(config()).await.unwrap();
let destination = ReplConfig {
limits: Some(ResourceLimits::default().max_memory(10 * 1024 * 1024)),
..ReplConfig::default()
};
let allocation = "large = b'a' * (12 * 1024 * 1024)\nlen(large)";
let mut fresh = pool.checkout(&destination).await.unwrap();
assert_eq!(
expect_complete(fresh.feed("6 * 7", vec![], vec![], false, &mut no_print).await.unwrap()),
MontyObject::int(42)
);
let error = fresh
.feed(allocation, vec![], vec![], false, &mut no_print)
.await
.unwrap_err();
assert!(matches!(error, PoolError::Runtime(ref exc) if exc.exc_type() == ExcType::MemoryError));
drop(fresh);
for suspended in [false, true] {
let mut source = pool
.checkout(&ReplConfig {
limits: Some(ResourceLimits::default().max_memory(64 * 1024 * 1024)),
..ReplConfig::default()
})
.await
.unwrap();
source
.feed(
if suspended { "x = 42\nfetch('x')" } else { "x = 42" },
vec![],
vec![],
false,
&mut no_print,
)
.await
.unwrap();
let state = source.dump().await.unwrap();
drop(source);
let mut restored = pool.checkout(&destination).await.unwrap();
let (event, _) = restored.restore(state, vec![], &mut no_print).await.unwrap();
if suspended {
assert!(matches!(event, Some(TurnEvent::FunctionCall { .. })));
restored
.resume(ResumeValue::Return(MontyObject::none()), &mut no_print)
.await
.unwrap();
} else {
assert!(event.is_none());
}
assert_eq!(
expect_complete(restored.feed("x", vec![], vec![], false, &mut no_print).await.unwrap()),
MontyObject::int(42)
);
let error = restored
.feed(allocation, vec![], vec![], false, &mut no_print)
.await
.unwrap_err();
assert!(matches!(error, PoolError::Runtime(ref exc) if exc.exc_type() == ExcType::MemoryError));
drop(restored);
}
let mut sibling = pool.checkout(&destination).await.unwrap();
assert_eq!(
expect_complete(
sibling
.feed("6 * 7", vec![], vec![], false, &mut no_print)
.await
.unwrap()
),
MontyObject::int(42)
);
sibling.finish().await.unwrap();
}
#[tokio::test]
async fn elastic_growth_up_to_max_then_exhausted() {
let mut config = config();
config.min_processes = 1;
config.max_processes = 2;
config.checkout_timeout = Some(Duration::from_millis(200));
let pool = Pool::new(config).await.unwrap();
let one = pool.checkout(&ReplConfig::default()).await.unwrap();
let two = pool.checkout(&ReplConfig::default()).await.unwrap(); let Err(err) = pool.checkout(&ReplConfig::default()).await else {
panic!("expected Exhausted");
};
assert!(matches!(err, PoolError::Exhausted), "got {err:?}");
one.finish().await.unwrap();
let three = pool.checkout(&ReplConfig::default()).await.unwrap(); three.finish().await.unwrap();
two.finish().await.unwrap();
assert_eq!(pool.idle_workers(), 2);
}
#[tokio::test]
async fn dropping_a_checkout_kills_the_worker_but_frees_capacity() {
let mut config = config();
config.max_processes = 1;
let pool = Pool::new(config).await.unwrap();
let mut session = pool.checkout(&ReplConfig::default()).await.unwrap();
let pid = session.pid().unwrap();
let _ = session
.feed("x = 1", vec![], vec![], false, &mut no_print)
.await
.unwrap();
drop(session);
let mut session = pool.checkout(&ReplConfig::default()).await.unwrap();
assert_ne!(session.pid().unwrap(), pid);
assert_eq!(
expect_complete(
session
.feed("5 + 5", vec![], vec![], false, &mut no_print)
.await
.unwrap()
),
MontyObject::int(10)
);
session.finish().await.unwrap();
}
#[tokio::test]
async fn workers_are_recycled_after_max_checkouts() {
let mut config = config();
config.max_checkouts_per_worker = Some(1);
let pool = Pool::new(config).await.unwrap();
let session = pool.checkout(&ReplConfig::default()).await.unwrap();
let first_pid = session.pid().unwrap();
let first_id = session.worker_id().unwrap();
session.finish().await.unwrap();
wait_for_idle(&pool, 1).await;
assert_ne!(pool.idle_worker_pids(), vec![first_pid]);
let session = pool.checkout(&ReplConfig::default()).await.unwrap();
assert_ne!(session.pid().unwrap(), first_pid);
assert_ne!(session.worker_id().unwrap(), first_id);
session.finish().await.unwrap();
}
#[tokio::test]
async fn workers_are_recycled_after_the_default_checkout_count() {
let mut config = config();
config.max_processes = 1;
assert_eq!(config.max_checkouts_per_worker, Some(DEFAULT_MAX_CHECKOUTS_PER_WORKER));
let pool = Pool::new(config).await.unwrap();
let mut first_id = None;
for _ in 0..DEFAULT_MAX_CHECKOUTS_PER_WORKER {
let session = pool.checkout(&ReplConfig::default()).await.unwrap();
let id = session.worker_id().unwrap();
assert_eq!(*first_id.get_or_insert(id), id);
session.finish().await.unwrap();
}
let session = pool.checkout(&ReplConfig::default()).await.unwrap();
assert_ne!(session.worker_id(), first_id);
session.finish().await.unwrap();
}
#[tokio::test]
async fn no_recycle_limit_keeps_the_worker() {
let mut config = config();
config.max_processes = 1;
config.max_checkouts_per_worker = None;
let pool = Pool::new(config).await.unwrap();
let session = pool.checkout(&ReplConfig::default()).await.unwrap();
let first_id = session.worker_id();
session.finish().await.unwrap();
for _ in 0..DEFAULT_MAX_CHECKOUTS_PER_WORKER {
let session = pool.checkout(&ReplConfig::default()).await.unwrap();
assert_eq!(session.worker_id(), first_id);
session.finish().await.unwrap();
}
}
#[tokio::test]
async fn recycled_workers_are_replaced_in_the_background() {
let mut config = config();
config.min_processes = 2;
config.max_processes = 2;
config.max_checkouts_per_worker = Some(1);
let pool = Pool::new(config).await.unwrap();
let mut seen = pool.idle_worker_pids();
for _ in 0..3 {
let session = pool.checkout(&ReplConfig::default()).await.unwrap();
let pid = session.pid().unwrap();
session.finish().await.unwrap();
wait_for_idle(&pool, 2).await;
for idle in pool.idle_worker_pids() {
if !seen.contains(&idle) {
seen.push(idle);
}
}
assert!(seen.contains(&pid));
}
assert_eq!(seen.len(), 5);
}
#[tokio::test]
async fn crashed_and_dropped_workers_are_replaced() {
let pool = Pool::new(config()).await.unwrap();
let session = pool.checkout(&ReplConfig::default()).await.unwrap();
let dropped_pid = session.pid().unwrap();
drop(session);
wait_for_idle(&pool, 1).await;
assert_ne!(pool.idle_worker_pids(), vec![dropped_pid]);
let mut session = pool.checkout(&ReplConfig::default()).await.unwrap();
let crashed_pid = session.pid().unwrap();
kill_pid(crashed_pid);
let err = session
.feed("1 + 1", vec![], vec![], false, &mut no_print)
.await
.unwrap_err();
assert!(matches!(err, PoolError::Crashed { .. }), "got {err:?}");
drop(session);
wait_for_idle(&pool, 1).await;
assert_ne!(pool.idle_worker_pids(), vec![crashed_pid]);
}
#[tokio::test]
async fn refill_never_exceeds_max_processes() {
let mut config = config();
config.min_processes = 2;
config.max_processes = 2;
config.max_checkouts_per_worker = Some(1);
config.checkout_timeout = Some(Duration::from_secs(1));
let pool = Pool::new(config).await.unwrap();
let first = pool.checkout(&ReplConfig::default()).await.unwrap();
let held = pool.checkout(&ReplConfig::default()).await.unwrap();
let first_pid = first.pid().unwrap();
first.finish().await.unwrap();
let refilled = pool.checkout(&ReplConfig::default()).await.unwrap();
assert_ne!(refilled.pid().unwrap(), first_pid);
assert_eq!(pool.idle_workers(), 0);
let blocked = pool.checkout(&ReplConfig::default()).await.err();
assert!(matches!(blocked, Some(PoolError::Exhausted)), "{blocked:?}");
drop(held);
drop(refilled);
wait_for_idle(&pool, 2).await;
}
#[tokio::test]
async fn reuse_pool_does_not_grow_past_its_load() {
let mut config = config();
config.max_processes = 3;
let pool = Pool::new(config).await.unwrap();
let repl = ReplConfig::default();
let (a, b, c) = tokio::join!(pool.checkout(&repl), pool.checkout(&repl), pool.checkout(&repl));
for session in [a, b, c] {
session.unwrap().finish().await.unwrap();
}
assert_eq!(pool.idle_workers(), 3);
sleep(Duration::from_millis(100)).await;
assert_eq!(pool.idle_workers(), 3);
}
#[tokio::test]
async fn concurrent_checkouts_run_in_parallel() {
let mut config = config();
config.min_processes = 2;
config.max_processes = 2;
let pool = Pool::new(config).await.unwrap();
let run_one = async {
let mut session = pool.checkout(&ReplConfig::default()).await.unwrap();
let event = session
.feed("sum(range(1000))", vec![], vec![], false, &mut no_print)
.await
.unwrap();
assert_eq!(expect_complete(event), MontyObject::int(499_500));
session.finish().await.unwrap();
};
tokio::join!(run_one, async {
let mut session = pool.checkout(&ReplConfig::default()).await.unwrap();
let event = session
.feed("sum(range(1000))", vec![], vec![], false, &mut no_print)
.await
.unwrap();
assert_eq!(expect_complete(event), MontyObject::int(499_500));
session.finish().await.unwrap();
});
assert_eq!(pool.idle_workers(), 2);
}
#[tokio::test]
async fn typing_error_via_pool() {
let pool = Pool::new(config()).await.unwrap();
let mut session = pool
.checkout(&ReplConfig {
type_check: true,
..ReplConfig::default()
})
.await
.unwrap();
let err = session
.feed("x: int = 'nope'", vec![], vec![], false, &mut no_print)
.await
.unwrap_err();
let PoolError::Typing(diagnostics) = err else {
panic!("expected Typing, got {err:?}");
};
assert!(diagnostics.contains("invalid-assignment"), "{diagnostics}");
assert_eq!(
expect_complete(
session
.feed("1 + 1", vec![], vec![], false, &mut no_print)
.await
.unwrap()
),
MontyObject::int(2)
);
session.finish().await.unwrap();
}
#[tokio::test]
async fn type_check_state_is_scrubbed_between_checkouts_on_the_same_worker() {
let concise = TypeCheckingConfig {
format: TypeCheckingFormat::Concise,
color: false,
};
let pool = Pool::new(config()).await.unwrap();
let mut session = pool
.checkout(&ReplConfig {
script_name: "a.py".to_owned(),
type_check: true,
type_check_stubs: Some("STUB_SECRET: str = 'from-stubs'".to_owned()),
type_check_config: concise,
..ReplConfig::default()
})
.await
.unwrap();
let first_pid = session.pid().unwrap();
let event = session
.feed("SECRET = 'hunter2'", vec![], vec![], false, &mut no_print)
.await
.unwrap();
assert_eq!(expect_complete(event), MontyObject::none());
session.finish().await.unwrap();
assert_eq!(pool.idle_workers(), 1);
let mut session = pool
.checkout(&ReplConfig {
script_name: "b.py".to_owned(),
type_check: true,
type_check_config: concise,
..ReplConfig::default()
})
.await
.unwrap();
assert_eq!(
session.pid().unwrap(),
first_pid,
"worker was not reused — test is vacuous"
);
let mut probe = async |code: &str| {
let fed = match session.feed(code, vec![], vec![], false, &mut no_print).await {
Ok(TurnEvent::OsCall { function_name, .. }) if function_name == IMPORT_STUBS_CALL => {
session.resume(ResumeValue::NotHandled, &mut no_print).await
}
other => other,
};
match fed {
Err(PoolError::Typing(diagnostics)) => diagnostics,
other => panic!("expected a typing rejection for {code:?}, got {other:?}"),
}
};
assert_eq!(
probe("from a import SECRET").await,
"b.py:1:6: error[unresolved-import] Cannot resolve imported module `a`\n"
);
assert_eq!(
probe("from repl_type_stubs import STUB_SECRET").await,
"b.py:1:6: error[unresolved-import] Cannot resolve imported module `repl_type_stubs`\n"
);
assert_eq!(
probe("x = STUB_SECRET").await,
"b.py:1:5: error[unresolved-reference] Name `STUB_SECRET` used when not defined\n"
);
let event = session
.feed("x: int = 1\nx", vec![], vec![], false, &mut no_print)
.await
.unwrap();
assert_eq!(expect_complete(event), MontyObject::int(1));
session.finish().await.unwrap();
}
#[tokio::test]
async fn termination_reaps_a_cancelled_turn_and_preserves_pool_capacity() {
let mut settings = config();
settings.max_processes = 1;
let pool = Pool::new(settings).await.unwrap();
let mut session = pool.checkout(&ReplConfig::default()).await.unwrap();
let worker = session.worker_id().unwrap();
{
let mut printer = no_print;
tokio::select! {
result = session.feed("while True:\n pass", vec![], vec![], false, &mut printer) => panic!("the controlled turn cannot finish: {result:?}"),
() = sleep(Duration::from_millis(10)) => {}
}
}
assert!(session.terminate().await.is_some());
let mut next = pool.checkout(&ReplConfig::default()).await.unwrap();
assert_ne!(next.worker_id(), Some(worker));
assert_eq!(
expect_complete(next.feed("6 * 7", vec![], vec![], false, &mut no_print).await.unwrap()),
MontyObject::int(42)
);
next.finish().await.unwrap();
}
#[tokio::test]
async fn termination_reaps_an_idle_checkout() {
let pool = Pool::new(config()).await.unwrap();
let session = pool.checkout(&ReplConfig::default()).await.unwrap();
assert!(session.terminate().await.is_some());
}
#[tokio::test]
async fn termination_without_an_owned_worker_does_not_confirm_exit() {
let pool = Pool::new(config()).await.unwrap();
let mut session = pool.checkout(&ReplConfig::default()).await.unwrap();
{
let mut printer = no_print;
tokio::select! {
result = session.feed("while True:\n pass", vec![], vec![], false, &mut printer) => panic!("the controlled turn cannot finish: {result:?}"),
() = sleep(Duration::from_millis(10)) => {}
}
}
assert!(matches!(
session.feed("6 * 7", vec![], vec![], false, &mut no_print).await,
Err(PoolError::Protocol(_))
));
assert!(session.terminate().await.is_none());
}
#[tokio::test]
async fn cancelled_turn_discards_the_worker_on_next_use() {
let pool = Pool::new(config()).await.unwrap();
let mut session = pool.checkout(&ReplConfig::default()).await.unwrap();
{
let mut printer = no_print;
let feed = session.feed("while True:\n pass", vec![], vec![], false, &mut printer);
tokio::select! {
_result = feed => panic!("the feed cannot complete"),
() = sleep(Duration::from_millis(10)) => {}
}
}
let err = session
.feed("1 + 1", vec![], vec![], false, &mut no_print)
.await
.unwrap_err();
let PoolError::Protocol(message) = err else {
panic!("expected Protocol, got {err:?}");
};
assert_eq!(
message,
"a previous protocol turn was cancelled mid-flight; the worker was discarded"
);
let mut session = pool.checkout(&ReplConfig::default()).await.unwrap();
let event = session
.feed("1 + 1", vec![], vec![], false, &mut no_print)
.await
.unwrap();
assert_eq!(expect_complete(event), MontyObject::int(2));
session.finish().await.unwrap();
}
#[tokio::test]
async fn cancelled_turn_discards_the_worker_on_resume_from_mounts() {
let pool = Pool::new(config()).await.unwrap();
let mut session = pool.checkout(&ReplConfig::default()).await.unwrap();
{
let mut printer = no_print;
let feed = session.feed("while True:\n pass", vec![], vec![], false, &mut printer);
tokio::select! {
_result = feed => panic!("the feed cannot complete"),
() = sleep(Duration::from_millis(10)) => {}
}
}
let err = session.resume_from_mounts(&mut no_print).await.unwrap_err();
let PoolError::Protocol(message) = err else {
panic!("expected Protocol, got {err:?}");
};
assert_eq!(
message,
"a previous protocol turn was cancelled mid-flight; the worker was discarded"
);
let err = session
.resume(ResumeValue::Return(MontyObject::none()), &mut no_print)
.await
.unwrap_err();
assert!(matches!(err, PoolError::Finished), "got {err:?}");
}
#[tokio::test]
async fn dump_survives_worker_death_and_loads_elsewhere() {
let pool = Pool::new(config()).await.unwrap();
let mut session = pool.checkout(&ReplConfig::default()).await.unwrap();
assert_eq!(
expect_complete(
session
.feed("base = 40", vec![], vec![], false, &mut no_print)
.await
.unwrap()
),
MontyObject::none()
);
let event = session
.feed("base + ext()", vec![], vec![], false, &mut no_print)
.await
.unwrap();
let TurnEvent::FunctionCall {
call_id: _,
ref function_name,
..
} = event
else {
panic!("expected FunctionCall, got {event:?}");
};
assert_eq!(function_name, "ext");
let state = session.dump().await.unwrap();
drop(session);
let mut restored = pool.checkout(&ReplConfig::default()).await.unwrap();
let (event, script_name) = restored.restore(state, vec![], &mut no_print).await.unwrap();
assert_eq!(script_name.as_deref(), Some("main.py"));
let Some(TurnEvent::FunctionCall { ref function_name, .. }) = event else {
panic!("expected re-announced FunctionCall, got {event:?}");
};
assert_eq!(function_name, "ext");
let event = restored
.resume(ResumeValue::Return(MontyObject::int(2)), &mut no_print)
.await
.unwrap();
assert_eq!(expect_complete(event), MontyObject::int(42));
restored.finish().await.unwrap();
}
#[tokio::test]
async fn a_sub_millisecond_interval_does_not_become_line_buffering() {
let pool = Pool::new(config()).await.unwrap();
let repl = ReplConfig {
print_flush_interval: Some(Duration::from_micros(400)),
..ReplConfig::default()
};
let mut session = pool.checkout(&repl).await.unwrap();
let mut events = 0usize;
session
.feed(
"for i in range(100):\n print(i)",
vec![],
vec![],
false,
&mut on_print_sync(|_, _: &str| events += 1),
)
.await
.unwrap();
assert!(events < 100, "expected batching, got one event per line ({events})");
session.finish().await.unwrap();
}
#[tokio::test]
async fn a_restored_session_honors_its_checkout_flush_interval() {
let pool = Pool::new(config()).await.unwrap();
let mut session = pool.checkout(&ReplConfig::default()).await.unwrap();
session
.feed("base = 1", vec![], vec![], false, &mut no_print)
.await
.unwrap();
let state = session.dump().await.unwrap();
drop(session);
let repl = ReplConfig {
print_flush_interval: Some(Duration::ZERO),
..ReplConfig::default()
};
let mut restored = pool.checkout(&repl).await.unwrap();
restored.restore(state, vec![], &mut no_print).await.unwrap();
let mut lines = Vec::new();
restored
.feed(
"for i in range(20):\n print(base + i)",
vec![],
vec![],
false,
&mut on_print_sync(|_, text: &str| lines.push(text.to_owned())),
)
.await
.unwrap();
assert_eq!(lines.len(), 20);
assert_eq!(lines[0], "1\n");
assert_eq!(lines[19], "20\n");
restored.finish().await.unwrap();
}
#[cfg(target_os = "linux")]
#[tokio::test]
async fn worker_environment_is_empty() {
assert!(env::var("PATH").is_ok(), "test process should have PATH set");
let pool = Pool::new(config()).await.unwrap();
let mut session = pool.checkout(&ReplConfig::default()).await.unwrap();
let environ = fs::read(format!("/proc/{}/environ", session.pid().unwrap())).unwrap();
assert!(
environ.is_empty(),
"worker environment should be empty, got: {}",
String::from_utf8_lossy(&environ).replace('\0', " ")
);
let event = session
.feed("1 + 1", vec![], vec![], false, &mut no_print)
.await
.unwrap();
assert_eq!(expect_complete(event), MontyObject::int(2));
session.finish().await.unwrap();
}
#[tokio::test]
async fn max_feed_duration_bounds_each_feed_without_killing_the_worker() {
let pool = Pool::new(config()).await.unwrap();
let mut session = pool
.checkout(&ReplConfig {
limits: Some(ResourceLimits::default().max_feed_duration(Duration::from_millis(100))),
..ReplConfig::default()
})
.await
.unwrap();
for _ in 0..3 {
let event = session
.feed("1 + 1", vec![], vec![], false, &mut no_print)
.await
.unwrap();
assert_eq!(expect_complete(event), MontyObject::int(2));
}
let err = session
.feed("while True:\n pass", vec![], vec![], false, &mut no_print)
.await
.unwrap_err();
let PoolError::Runtime(exc) = err else {
panic!("expected Runtime, got {err:?}");
};
assert_eq!(exc.exc_type().to_string(), "TimeoutError");
let event = session
.feed("2 + 2", vec![], vec![], false, &mut no_print)
.await
.unwrap();
assert_eq!(expect_complete(event), MontyObject::int(4));
session.finish().await.unwrap();
assert_eq!(pool.idle_workers(), 1);
}
#[tokio::test]
async fn max_turn_duration_bounds_a_runaway_turn() {
let pool = Pool::new(config()).await.unwrap();
let mut session = pool
.checkout(&ReplConfig {
limits: Some(ResourceLimits::default().max_turn_duration(Duration::from_millis(100))),
..ReplConfig::default()
})
.await
.unwrap();
let err = session
.feed("while True:\n pass", vec![], vec![], false, &mut no_print)
.await
.unwrap_err();
let PoolError::Runtime(exc) = err else {
panic!("expected Runtime, got {err:?}");
};
assert_eq!(exc.exc_type().to_string(), "TimeoutError");
session.finish().await.unwrap();
assert_eq!(pool.idle_workers(), 1);
}
#[tokio::test]
async fn huge_feed_and_turn_budgets_do_not_overflow_the_backstop() {
let pool = Pool::new(config()).await.unwrap();
let mut session = pool
.checkout(&ReplConfig {
limits: Some(
ResourceLimits::default()
.max_feed_duration(Duration::MAX)
.max_turn_duration(Duration::MAX),
),
..ReplConfig::default()
})
.await
.unwrap();
let event = session
.feed("1 + 1", vec![], vec![], false, &mut no_print)
.await
.unwrap();
assert_eq!(expect_complete(event), MontyObject::int(2));
}
#[cfg(unix)]
#[tokio::test]
async fn a_rewound_feed_clock_cannot_loosen_the_feed_backstop() {
let dir = tempfile::tempdir().unwrap();
let suspension = |feed_execution_micros| pb::ChildEvent {
feed_execution_micros,
..child_event(pb::child_event::Kind::NameLookup(pb::NameLookup {
name: "x".to_owned(),
object_id: None,
position: Some(position()),
}))
};
let mut replies = framed(&child_event(pb::child_event::Kind::Ok(pb::Ok {})));
replies.extend(framed(&suspension(60_000_000)));
replies.extend(framed(&suspension(0)));
let replies_path = dir.path().join("replies.bin");
fs::write(&replies_path, &replies).unwrap();
let fake = write_fake_monty(
dir.path(),
&format!("#!/bin/sh\ncat '{}'\nsleep 30\n", replies_path.display()),
);
let grace = Duration::from_millis(100);
let mut config = PoolConfig::subprocess(&fake);
config.feed_duration_limit_grace = Some(grace);
config.request_timeout = None;
let pool = Pool::new(config).await.unwrap();
let mut checkout = pool
.checkout(&ReplConfig {
limits: Some(ResourceLimits::default().max_feed_duration(Duration::from_secs(60))),
..ReplConfig::default()
})
.await
.expect("the stand-in answers Configure with Ok");
let feed = pb::ParentRequest {
kind: Some(pb::parent_request::Kind::Feed(pb::Feed {
code: "x".to_owned(),
inputs: vec![].into(),
values: None,
skip_type_check: false,
cwd: "/".to_owned(),
})),
..pb::ParentRequest::default()
};
let resume = pb::ParentRequest {
kind: Some(pb::parent_request::Kind::ResumeNameLookup(pb::ResumeNameLookup {
kind: Some(pb::resume_name_lookup::Kind::Undefined(pb::Unit {})),
values: None,
})),
..pb::ParentRequest::default()
};
let mut on_event = |_: &pb::ChildEvent| Box::pin(ready(())) as PrintFuture;
checkout.turn_raw(&feed, &mut on_event).await.unwrap();
checkout.turn_raw(&resume, &mut on_event).await.unwrap();
let outcome = timeout(Duration::from_secs(5), checkout.turn_raw(&resume, &mut on_event)).await;
let Ok(Err(PoolError::Timeout { timeout })) = outcome else {
panic!("the spent feed budget must still backstop the next turn, got {outcome:?}");
};
assert_eq!(timeout, grace);
}
#[cfg(unix)]
#[tokio::test]
async fn a_suspension_without_a_position_reads_as_unknown() {
let dir = tempfile::tempdir().unwrap();
let mut replies = framed(&child_event(pb::child_event::Kind::Ok(pb::Ok {})));
replies.extend(framed(&child_event(pb::child_event::Kind::NameLookup(
pb::NameLookup {
name: "x".to_owned(),
object_id: None,
position: None,
},
))));
let replies_path = dir.path().join("replies.bin");
fs::write(&replies_path, &replies).unwrap();
let fake = write_fake_monty(
dir.path(),
&format!("#!/bin/sh\ncat '{}'\nsleep 30\n", replies_path.display()),
);
let pool = Pool::new(PoolConfig::subprocess(&fake)).await.unwrap();
let mut checkout = pool
.checkout(&ReplConfig::default())
.await
.expect("the stand-in answers Configure with Ok");
let event = checkout.feed("x", vec![], vec![], false, &mut no_print).await.unwrap();
let TurnEvent::NameLookup { name, position, .. } = event else {
panic!("expected a name lookup, got {event:?}");
};
assert_eq!(name, "x");
assert_eq!(position, SourceRange::unknown());
}
#[cfg(unix)]
#[tokio::test]
async fn a_raw_feed_restarts_the_parent_feed_clock() {
let dir = tempfile::tempdir().unwrap();
let budget = Duration::from_millis(300);
let grace = Duration::from_millis(100);
let mut replies = framed(&child_event(pb::child_event::Kind::Ok(pb::Ok {})));
replies.extend(framed(&pb::ChildEvent {
feed_execution_micros: u64::try_from(budget.as_micros()).unwrap(),
..child_event(pb::child_event::Kind::NameLookup(pb::NameLookup {
name: "x".to_owned(),
object_id: None,
position: Some(position()),
}))
}));
let replies_path = dir.path().join("replies.bin");
fs::write(&replies_path, &replies).unwrap();
let fake = write_fake_monty(
dir.path(),
&format!("#!/bin/sh\ncat '{}'\nsleep 30\n", replies_path.display()),
);
let mut config = PoolConfig::subprocess(&fake);
config.feed_duration_limit_grace = Some(grace);
config.request_timeout = None;
let pool = Pool::new(config).await.unwrap();
let mut checkout = pool
.checkout(&ReplConfig {
limits: Some(ResourceLimits::default().max_feed_duration(budget)),
..ReplConfig::default()
})
.await
.expect("the stand-in answers Configure with Ok");
let feed = pb::ParentRequest {
kind: Some(pb::parent_request::Kind::Feed(pb::Feed {
code: "x".to_owned(),
inputs: vec![].into(),
values: None,
skip_type_check: false,
cwd: "/".to_owned(),
})),
..pb::ParentRequest::default()
};
let mut on_event = |_: &pb::ChildEvent| Box::pin(ready(())) as PrintFuture;
checkout.turn_raw(&feed, &mut on_event).await.unwrap();
let err = checkout
.turn_raw(&feed, &mut on_event)
.await
.expect_err("the stand-in never answers the second feed");
let PoolError::Timeout { timeout } = err else {
panic!("expected the feed backstop to fire, got {err:?}");
};
assert_eq!(timeout, budget + grace);
}
#[cfg(unix)]
#[tokio::test]
async fn a_load_reply_cannot_loosen_the_feed_backstop() {
let dir = tempfile::tempdir().unwrap();
let budget = Duration::from_millis(300);
let grace = Duration::from_millis(100);
let mut replies = framed(&child_event(pb::child_event::Kind::Ok(pb::Ok {})));
replies.extend(framed(&pb::ChildEvent {
max_feed_duration_micros: Some(3_600_000_000),
..child_event(pb::child_event::Kind::Ok(pb::Ok {}))
}));
let replies_path = dir.path().join("replies.bin");
fs::write(&replies_path, &replies).unwrap();
let fake = write_fake_monty(
dir.path(),
&format!("#!/bin/sh\ncat '{}'\nsleep 30\n", replies_path.display()),
);
let mut config = PoolConfig::subprocess(&fake);
config.feed_duration_limit_grace = Some(grace);
config.request_timeout = None;
let pool = Pool::new(config).await.unwrap();
let mut checkout = pool
.checkout(&ReplConfig {
limits: Some(ResourceLimits::default().max_feed_duration(budget)),
..ReplConfig::default()
})
.await
.expect("the stand-in answers Configure with Ok");
let (event, _) = checkout.restore(vec![0], vec![], &mut no_print).await.unwrap();
assert!(event.is_none());
let outcome = timeout(
Duration::from_secs(5),
checkout.feed("x", vec![], vec![], false, &mut no_print),
)
.await;
let Ok(Err(PoolError::Timeout { timeout })) = outcome else {
panic!("the configured feed budget must backstop the restored feed, got {outcome:?}");
};
assert_eq!(timeout, budget + grace);
}
#[tokio::test]
async fn a_disabled_grace_leaves_the_sandbox_limit_in_charge() {
let mut pool_config = config();
pool_config.feed_duration_limit_grace = None;
pool_config.turn_duration_limit_grace = None;
let pool = Pool::new(pool_config).await.unwrap();
let mut session = pool
.checkout(&ReplConfig {
limits: Some(ResourceLimits::default().max_feed_duration(Duration::from_millis(100))),
..ReplConfig::default()
})
.await
.unwrap();
let err = session
.feed("while True:\n pass", vec![], vec![], false, &mut no_print)
.await
.unwrap_err();
let PoolError::Runtime(exc) = err else {
panic!("expected Runtime, got {err:?}");
};
assert_eq!(exc.exc_type().to_string(), "TimeoutError");
session.finish().await.unwrap();
assert_eq!(pool.idle_workers(), 1);
}
#[cfg(unix)]
fn position() -> pb::SourceRange {
pb::SourceRange {
filename: "main.py".to_owned(),
start: 0,
end: 1,
}
}
fn host_module(name: &str, attrs: Vec<(&str, MontyObject)>) -> MontyObject {
MontyObject::module(
name,
MontyUuid::from_u128(2),
attrs.into_iter().map(|(k, v)| (MontyObject::string(k), v)),
)
}
fn expect_import(event: TurnEvent) -> String {
expect_import_args(event).module
}
fn expect_import_args(event: TurnEvent) -> ImportArgs {
let TurnEvent::OsCall {
function_name, args, ..
} = event
else {
panic!("expected the import to suspend, got {event:?}");
};
assert_eq!(function_name, IMPORT_CALL);
ImportArgs::from_call_args(&args).expect("well-formed import arguments")
}
fn expect_import_stubs(event: TurnEvent) -> Vec<String> {
let TurnEvent::OsCall {
function_name, args, ..
} = event
else {
panic!("expected the stubs ask, got {event:?}");
};
assert_eq!(function_name, IMPORT_STUBS_CALL);
let names: Vec<_> = args.args().map(|arg| arg.to_owned()).collect();
let [names] = names.as_slice() else {
panic!("expected one list of names, got {names:?}");
};
names
.as_ref()
.items()
.expect("a list")
.iter()
.map(|name| name.as_str().expect("a module name").to_owned())
.collect()
}
fn tools_stubs(source: &str) -> ResumeValue {
ResumeValue::Return(MontyObject::dict([(
MontyObject::string("tools"),
MontyObject::string(source),
)]))
}
#[tokio::test]
async fn an_import_is_answered_by_the_host() {
let pool = Pool::new(config()).await.unwrap();
let mut session = pool.checkout(&ReplConfig::default()).await.unwrap();
let event = session
.feed("import tools\ntools.add(1, 2)", vec![], vec![], false, &mut no_print)
.await
.unwrap();
assert_eq!(expect_import(event), "tools");
let tools = host_module("tools", vec![("add", MontyObject::function("add", None))]);
let event = session.resume(ResumeValue::Return(tools), &mut no_print).await.unwrap();
let TurnEvent::FunctionCall {
function_name, args, ..
} = event
else {
panic!("expected the tool call, got {event:?}");
};
assert_eq!(function_name, "add");
let summands: Vec<_> = args.args().map(|arg| arg.to_owned()).collect();
assert_eq!(summands, vec![MontyObject::int(1), MontyObject::int(2)]);
let event = session
.resume(ResumeValue::Return(MontyObject::int(3)), &mut no_print)
.await
.unwrap();
assert_eq!(expect_complete(event), MontyObject::int(3));
let event = session
.feed("import nope", vec![], vec![], false, &mut no_print)
.await
.unwrap();
assert_eq!(expect_import(event), "nope");
let err = session.resume(ResumeValue::NotFound, &mut no_print).await.unwrap_err();
let PoolError::Runtime(exc) = err else {
panic!("expected Runtime, got {err:?}");
};
assert_snapshot!(exc.to_string(), @r#"
Traceback (most recent call last):
File "<python-input-1>", line 1, in <module>
import nope
ModuleNotFoundError: No module named 'nope'
"#);
session.finish().await.unwrap();
}
#[tokio::test]
async fn module_stubs_type_check_imports() {
let pool = Pool::new(config()).await.unwrap();
let mut session = pool.checkout(&type_checked()).await.unwrap();
let event = session
.feed(
"from tools import add\nadd('x', 2)",
vec![],
vec![],
false,
&mut no_print,
)
.await
.unwrap();
assert_eq!(expect_import_stubs(event), vec!["tools".to_owned()]);
let err = session
.resume(tools_stubs("def add(a: int, b: int) -> int: ...\n"), &mut no_print)
.await
.unwrap_err();
let PoolError::Typing(diagnostics) = err else {
panic!("expected Typing, got {err:?}");
};
assert_snapshot!(diagnostics, @r#"main.py:2:5: error[invalid-argument-type] Argument to function `add` is incorrect: Expected `int`, found `Literal["x"]`"#);
let event = session
.feed("import math as m", vec![], vec![], false, &mut no_print)
.await
.unwrap();
assert_eq!(expect_complete(event), MontyObject::none());
let err = session
.feed("m.sqrt('4')", vec![], vec![], false, &mut no_print)
.await
.unwrap_err();
assert!(matches!(err, PoolError::Typing(_)), "expected Typing, got {err:?}");
let event = session
.feed("import tools", vec![], vec![], false, &mut no_print)
.await
.unwrap();
assert_eq!(expect_import(event), "tools");
session.finish().await.unwrap();
}
fn type_checked() -> ReplConfig {
ReplConfig {
type_check: true,
type_check_config: TypeCheckingConfig {
format: TypeCheckingFormat::Concise,
color: false,
},
..ReplConfig::default()
}
}
#[tokio::test]
async fn module_stubs_do_not_outlive_their_session_on_a_reused_worker() {
let pool = Pool::new(config()).await.unwrap();
let mut session = pool.checkout(&type_checked()).await.unwrap();
let first_id = session.worker_id().unwrap();
let event = session
.feed("import tools", vec![], vec![], false, &mut no_print)
.await
.unwrap();
assert_eq!(expect_import_stubs(event), vec!["tools".to_owned()]);
let event = session
.resume(tools_stubs("def add(a: int, b: int) -> int: ...\n"), &mut no_print)
.await
.unwrap();
assert_eq!(expect_import(event), "tools");
let tools = host_module("tools", vec![]);
let event = session.resume(ResumeValue::Return(tools), &mut no_print).await.unwrap();
assert_eq!(expect_complete(event), MontyObject::none());
session.finish().await.unwrap();
let mut session = pool.checkout(&type_checked()).await.unwrap();
assert_eq!(session.worker_id(), Some(first_id));
let event = session
.feed("import tools", vec![], vec![], false, &mut no_print)
.await
.unwrap();
assert_eq!(expect_import_stubs(event), vec!["tools".to_owned()]);
let err = session
.resume(ResumeValue::NotHandled, &mut no_print)
.await
.unwrap_err();
let PoolError::Typing(diagnostics) = err else {
panic!("expected Typing, got {err:?}");
};
assert_snapshot!(diagnostics, @"main.py:1:8: error[unresolved-import] Cannot resolve imported module `tools`");
session.finish().await.unwrap();
}
#[tokio::test]
async fn invalid_stub_answers_without_cpu_supervision_remain_resumable() {
let pool = Pool::new(config()).await.unwrap();
for answer in [ResumeValue::Future, ResumeValue::Return(MontyObject::int(42))] {
let mut session = pool
.checkout(&ReplConfig {
type_check: true,
..ReplConfig::default()
})
.await
.unwrap();
let event = session
.feed("import tools", vec![], vec![], false, &mut no_print)
.await
.unwrap();
assert_eq!(expect_import_stubs(event), vec!["tools".to_owned()]);
assert!(matches!(
session.resume(answer, &mut no_print).await,
Err(PoolError::Protocol(_))
));
let error = MontyException::new(ExcType::RuntimeError, Some("stub provider failed".to_owned()));
let result = session.resume(ResumeValue::Error(error), &mut no_print).await;
assert!(
matches!(result, Err(PoolError::Runtime(ref error)) if error.message() == Some("stub provider failed"))
);
let event = session
.feed("1 + 1", vec![], vec![], false, &mut no_print)
.await
.unwrap();
assert_eq!(expect_complete(event), MontyObject::int(2));
session.finish().await.unwrap();
}
pool.close().await;
}
#[cfg(target_os = "linux")]
#[path = "support/cpu_limits.rs"]
mod cpu_limits;
#[cfg(not(target_os = "linux"))]
#[tokio::test]
async fn cpu_clock_is_rejected_off_linux() {
let mut config = config();
config.execution_clock = ExecutionClock::Cpu;
config.request_timeout = Some(Duration::from_secs(2));
let Err(error) = Pool::new(config).await else {
panic!("CPU pool must be rejected off Linux");
};
assert_eq!(
error.to_string(),
"failed to spawn monty worker: CPU execution clock is only supported for subprocess workers on Linux"
);
}
#[tokio::test]
async fn a_host_decode_budget_rejects_a_subprocess_result() {
let mut config = config();
config.max_decode_bytes = 4096;
let pool = Pool::new(config).await.unwrap();
let mut session = pool.checkout(&ReplConfig::default()).await.unwrap();
let result = session.feed("42", vec![], vec![], false, &mut no_print).await.unwrap();
assert_eq!(expect_complete(result), MontyObject::int(42));
let error = session
.feed("[0] * 100_000", vec![], vec![], false, &mut no_print)
.await
.unwrap_err();
assert!(matches!(error, PoolError::Protocol(_)));
assert!(session.worker_id().is_none());
drop(session);
let mut next = pool.checkout(&ReplConfig::default()).await.unwrap();
let result = next.feed("42", vec![], vec![], false, &mut no_print).await.unwrap();
assert_eq!(expect_complete(result), MontyObject::int(42));
next.finish().await.unwrap();
}