1use std::sync::mpsc;
4
5use async_trait::async_trait;
6use monty::{MontyRun, RunProgress};
7use monty_types::{
8 CompileOptions, ExcType, ExtFunctionResult, MontyException, MontyObject, NameLookupResult,
9 ObjectRef, PrintWriter, ResourceLimits, ResourceTracker,
10};
11use serde_json::{Map, Value, json};
12use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender, unbounded_channel};
13
14use super::CodeLimits;
15use super::sandbox::{CodeSandbox, Execution, Suspension};
16
17#[derive(Debug, Clone, Copy, Default)]
26pub struct InProcess;
27
28#[async_trait]
29impl CodeSandbox for InProcess {
30 async fn start(
31 &self,
32 program: &str,
33 functions: &[String],
34 limits: &CodeLimits,
35 ) -> (Box<dyn Execution>, Suspension) {
36 let (stops, stopped) = unbounded_channel();
37 let (answers, answered) = mpsc::channel();
38 let program = program.to_string();
39 let functions = functions.to_vec();
40 let limits = *limits;
41 tokio::task::spawn_blocking(move || run(program, functions, limits, &stops, &answered));
42 let mut execution = Thread { stopped, answers };
43 let first = execution.next().await;
44 (Box::new(execution), first)
45 }
46}
47
48struct Thread {
50 stopped: UnboundedReceiver<Suspension>,
51 answers: mpsc::Sender<Result<Value, String>>,
52}
53
54impl Thread {
55 async fn next(&mut self) -> Suspension {
56 self.stopped
57 .recv()
58 .await
59 .unwrap_or_else(|| Suspension::failed("the sandbox stopped"))
60 }
61}
62
63#[async_trait]
64impl Execution for Thread {
65 async fn resume(&mut self, answer: Result<Value, String>) -> Suspension {
66 if self.answers.send(answer).is_err() {
67 return Suspension::failed("the program has ended");
68 }
69 self.next().await
70 }
71}
72
73fn run(
76 program: String,
77 functions: Vec<String>,
78 limits: CodeLimits,
79 stops: &UnboundedSender<Suspension>,
80 answers: &mpsc::Receiver<Result<Value, String>>,
81) {
82 let cap = Some(limits.output_chars.saturating_mul(16).max(1 << 16));
86 let mut printed = String::new();
87 let finish = |result: Result<Value, String>, printed: String| {
88 let _ = stops.send(Suspension::Finished { result, printed });
89 };
90 let runner = match MontyRun::new(
91 program,
92 "main.py",
93 functions.clone(),
94 CompileOptions::default(),
95 ) {
96 Ok(runner) => runner,
97 Err(e) => return finish(Err(e.to_string()), printed),
98 };
99 let tracker = ResourceTracker::new(ResourceLimits {
100 max_feed_duration: Some(limits.cpu),
101 ..ResourceLimits::default()
102 });
103 let inputs = functions
104 .iter()
105 .map(|name| MontyObject::function(name.clone(), None))
106 .collect();
107 let mut progress = runner.start(
108 inputs,
109 tracker,
110 PrintWriter::CollectString(&mut printed, cap),
111 );
112 loop {
113 let step = match progress {
114 Err(e) => return finish(Err(e.to_string()), printed),
115 Ok(step) => step,
116 };
117 progress = match step {
118 RunProgress::Complete(value) => {
119 return finish(Ok(to_json(value.as_ref())), printed);
120 }
121 RunProgress::FunctionCall(call) if !functions.contains(&call.function_name) => {
124 let name = call.function_name.clone();
125 call.resume(
126 ExtFunctionResult::NotFound(name),
127 PrintWriter::CollectString(&mut printed, cap),
128 )
129 }
130 RunProgress::FunctionCall(call) => {
131 let positional = call.args.args().map(to_json).collect();
132 let keywords: Map<String, Value> = call
133 .args
134 .kwargs()
135 .map(|(k, v)| (key(k), to_json(v)))
136 .collect();
137 let stop = Suspension::Call {
138 function: call.function_name.clone(),
139 positional,
140 keywords,
141 };
142 if stops.send(stop).is_err() {
143 return;
144 }
145 let Ok(answer) = answers.recv() else {
147 return;
148 };
149 let print = PrintWriter::CollectString(&mut printed, cap);
150 match answer {
151 Ok(value) => call.resume(from_json(&value), print),
152 Err(reason) => call.resume(
153 MontyException::new(ExcType::RuntimeError, Some(reason)),
154 print,
155 ),
156 }
157 }
158 RunProgress::NameLookup(lookup) => lookup.resume(
159 NameLookupResult::Undefined,
160 PrintWriter::CollectString(&mut printed, cap),
161 ),
162 RunProgress::OsCall(os) => os.resume(
163 MontyException::new(
164 ExcType::PermissionError,
165 Some("this sandbox has no file system, network or clock access".into()),
166 ),
167 PrintWriter::CollectString(&mut printed, cap),
168 ),
169 RunProgress::ResolveFutures(_) => {
170 return finish(
171 Err("async code is not supported; call the functions directly".into()),
172 printed,
173 );
174 }
175 };
176 }
177}
178
179pub(super) fn key(k: ObjectRef<'_>) -> String {
180 k.as_str().map_or_else(|| k.py_repr(), str::to_string)
181}
182
183pub(super) fn to_json(o: ObjectRef<'_>) -> Value {
186 match o.type_name() {
187 "NoneType" => Value::Null,
188 "bool" => o.as_bool().map_or(Value::Null, Value::Bool),
189 "int" => o
190 .as_int()
191 .map_or_else(|| Value::String(o.py_repr()), |i| json!(i)),
192 "float" => o
193 .as_float()
194 .and_then(serde_json::Number::from_f64)
195 .map_or(Value::Null, Value::Number),
196 "str" => o
197 .as_str()
198 .map_or(Value::Null, |s| Value::String(s.to_string())),
199 "list" | "tuple" => Value::Array(
200 o.items()
201 .unwrap_or_default()
202 .into_iter()
203 .map(to_json)
204 .collect(),
205 ),
206 "dict" => Value::Object(
207 o.pairs()
208 .unwrap_or_default()
209 .into_iter()
210 .map(|(k, v)| (key(k), to_json(v)))
211 .collect(),
212 ),
213 _ => Value::String(o.py_repr()),
214 }
215}
216
217pub(super) fn from_json(v: &Value) -> MontyObject {
219 match v {
220 Value::Null => MontyObject::none(),
221 Value::Bool(b) => MontyObject::bool(*b),
222 Value::Number(n) => n.as_i64().map_or_else(
223 || MontyObject::float(n.as_f64().unwrap_or(f64::NAN)),
224 MontyObject::int,
225 ),
226 Value::String(s) => MontyObject::string(s.clone()),
227 Value::Array(a) => MontyObject::list(a.iter().map(from_json)),
228 Value::Object(m) => MontyObject::dict(
229 m.iter()
230 .map(|(k, v)| (MontyObject::string(k.clone()), from_json(v))),
231 ),
232 }
233}
234
235#[cfg(test)]
236mod tests {
237 use std::time::Duration;
238
239 use super::*;
240
241 async fn finish(program: &str) -> Suspension {
242 let (_, stop) = InProcess.start(program, &[], &CodeLimits::default()).await;
243 stop
244 }
245
246 #[tokio::test]
247 async fn a_program_returns_its_last_expression_as_json() {
248 let stop = finish("x = [1, 2.5, 'a', None, True]\n{'x': x, 'n': len(x)}").await;
249 assert_eq!(
250 stop,
251 Suspension::Finished {
252 result: Ok(json!({"x": [1, 2.5, "a", null, true], "n": 5})),
253 printed: String::new(),
254 }
255 );
256 }
257
258 #[tokio::test]
259 async fn dates_and_printing_work() {
260 let stop = finish(
261 "import datetime\nd = datetime.date(2026, 10, 11)\nprint('day', d.weekday())\nd.isoformat()",
262 )
263 .await;
264 assert_eq!(
265 stop,
266 Suspension::Finished {
267 result: Ok(json!("2026-10-11")),
268 printed: "day 6\n".into(),
269 }
270 );
271 }
272
273 #[tokio::test]
274 async fn a_loop_ends_at_the_time_limit() {
275 let limits = CodeLimits {
276 cpu: Duration::from_millis(200),
277 ..CodeLimits::default()
278 };
279 let (_, stop) = InProcess.start("while True:\n pass", &[], &limits).await;
280 let Suspension::Finished { result: Err(e), .. } = stop else {
281 panic!("expected a timeout, got {stop:?}");
282 };
283 assert!(e.contains("TimeoutError"), "{e}");
284 }
285
286 #[tokio::test]
287 async fn the_file_system_is_refused() {
288 let stop = finish("from pathlib import Path\nPath('/etc/passwd').read_text()").await;
289 let Suspension::Finished { result: Err(e), .. } = stop else {
290 panic!("expected a refusal, got {stop:?}");
291 };
292 assert!(e.contains("PermissionError"), "{e}");
293 }
294
295 #[tokio::test]
296 async fn a_call_suspends_until_the_host_answers() {
297 let functions = vec!["double".to_string()];
298 let (mut run, stop) = InProcess
299 .start(
300 "try:\n double(1)\nexcept RuntimeError as e:\n r = str(e)\n[double(n=21), r]",
301 &functions,
302 &CodeLimits::default(),
303 )
304 .await;
305 assert_eq!(
306 stop,
307 Suspension::Call {
308 function: "double".into(),
309 positional: vec![json!(1)],
310 keywords: Map::new(),
311 }
312 );
313 let stop = run.resume(Err("not yet".into())).await;
314 let Suspension::Call { keywords, .. } = stop else {
315 panic!("expected a second call, got {stop:?}");
316 };
317 assert_eq!(keywords.get("n"), Some(&json!(21)));
318 let stop = run.resume(Ok(json!({"value": 42}))).await;
319 assert_eq!(
320 stop,
321 Suspension::Finished {
322 result: Ok(json!([{"value": 42}, "not yet"])),
323 printed: String::new(),
324 }
325 );
326 }
327
328 #[tokio::test]
329 async fn an_undefined_name_raises_name_error() {
330 let stop = finish("lookup_everything()").await;
331 let Suspension::Finished { result: Err(e), .. } = stop else {
332 panic!("expected a NameError, got {stop:?}");
333 };
334 assert!(e.contains("NameError"), "{e}");
335 }
336}