Skip to main content

brep_script_core/
worker.rs

1//! The code-mode worker: one restricted JavaScript execution in a process of
2//! its own, on Boa, unchanged. A host binary becomes this process when it is
3//! started with the hidden worker flag (`BREP_mcp_core` `cad::code::worker_entry`).
4//!
5//! The host that starts this process owns it: it watches the process's CPU
6//! time, memory and age, and KILLS it — on an agent's halt
7//! (`cad_code_cancel`) or a limit — which stops any running script, however
8//! it loops. Nothing in the host's address space is reachable from the
9//! script, and a killed worker leaves nothing behind but the CAD operations
10//! it had already handed to the service.
11//!
12//! Protocol, one JSON object per line:
13//!
14//! - stdin, first line: `{"source", "sdk", "manifest"}` — the program (an ES
15//!   module with `export default async function main()`), the `@brep/cad`
16//!   module source, and the manifest identity as JSON text;
17//! - stdin, then: `{"reply": id, "body": text}` — a host call's answer;
18//! - stdout: `{"call": id, "method": m, "body": text}` — a host call (the
19//!   global `__brep_host(method, body)` the SDK uses, answered by a Promise);
20//!   `{"log": line}`; and last `{"done": {"ok": json}}` or
21//!   `{"done": {"error": {"code", "message"}}}`.
22//!
23//! The only importable module is `@brep/cad`; there is no filesystem,
24//! network, process, timer or environment binding.
25//!
26//! The same protocol drives the browser's code worker (a Web Worker running
27//! this crate in the app's wasm, messages in place of lines): `Realm` is the
28//! event-driven core both share — `Realm::begin` runs the program until it
29//! waits on the host or ends, `Realm::answer` delivers one host reply and
30//! runs on. `main` is that core over stdin and stdout.
31use crate::{describe, install_console, Logs};
32use boa_engine::builtins::promise::PromiseState;
33use boa_engine::module::{ModuleLoader, Referrer};
34use boa_engine::object::builtins::{JsFunction, JsPromise};
35use boa_engine::{js_string, Context, JsError, JsNativeError, JsResult, JsValue, Module, NativeFunction, Source};
36use serde_json::{json, Value};
37use std::cell::RefCell;
38use std::collections::HashMap;
39#[cfg(not(target_arch = "wasm32"))]
40use std::io::{BufRead, Write};
41use std::rc::Rc;
42
43struct OnlySdk {
44    sdk: String,
45    parsed: RefCell<Option<Module>>,
46}
47
48impl ModuleLoader for OnlySdk {
49    async fn load_imported_module(
50        self: Rc<Self>,
51        _referrer: Referrer,
52        request: boa_engine::module::ModuleRequest,
53        context: &RefCell<&mut Context>,
54    ) -> JsResult<Module> {
55        let spec = request.specifier().to_std_string_escaped();
56        if spec != "@brep/cad" {
57            return Err(JsNativeError::typ().with_message(format!("cannot import `{spec}`: the only importable module is @brep/cad")).into());
58        }
59        if let Some(m) = self.parsed.borrow().as_ref() {
60            return Ok(m.clone());
61        }
62        let mut cx = context.borrow_mut();
63        let module = Module::parse(Source::from_bytes(&self.sdk).with_path(std::path::Path::new("@brep/cad")), None, &mut cx)?;
64        *self.parsed.borrow_mut() = Some(module.clone());
65        Ok(module)
66    }
67}
68
69thread_local! {
70    static PENDING: RefCell<HashMap<u64, (JsFunction, JsFunction)>> = RefCell::new(HashMap::new());
71    static NEXT: RefCell<u64> = const { RefCell::new(0) };
72    /// Where this realm's protocol messages go: stdout natively, the Web
73    /// Worker's `postMessage` in the browser.
74    static SINK: RefCell<Option<Box<dyn Fn(&Value)>>> = RefCell::new(None);
75}
76
77fn emit(line: &Value) {
78    SINK.with(|s| {
79        if let Some(sink) = s.borrow().as_ref() {
80            sink(line);
81        }
82    });
83}
84
85fn done_error(code: &str, message: String) -> Value {
86    json!({ "done": { "error": { "code": code, "message": message } } })
87}
88
89/// `__brep_host(method, body)`: send the call to the host and answer a
90/// pending Promise, settled when the host's reply arrives.
91fn host_call(_: &JsValue, args: &[JsValue], cx: &mut Context) -> JsResult<JsValue> {
92    let text = |i: usize| args.get(i).and_then(JsValue::as_string).map(|s| s.to_std_string_escaped()).unwrap_or_default();
93    let (method, body) = (text(0), text(1));
94    let (promise, resolvers) = JsPromise::new_pending(cx);
95    let id = NEXT.with(|n| {
96        *n.borrow_mut() += 1;
97        *n.borrow()
98    });
99    PENDING.with(|p| p.borrow_mut().insert(id, (resolvers.resolve, resolvers.reject)));
100    emit(&json!({ "call": id, "method": method, "body": body }));
101    Ok(promise.into())
102}
103
104/// Boa's own runtime limits (loop iterations per frame, recursion, stack)
105/// are uncatchable in JavaScript; reported as the resource limit they are.
106fn fail(code: &str, e: JsError, cx: &mut Context) -> Value {
107    let limit = e.as_engine().is_some();
108    let message = describe(e, cx);
109    done_error(if limit || message.contains("RuntimeLimitError") { "resource_limit" } else { code }, message)
110}
111
112/// One execution, driven by its host's messages.
113pub struct Realm {
114    cx: Context,
115    logs: Logs,
116    sent: usize,
117    main: JsPromise,
118}
119
120impl Realm {
121    /// Start an execution from its start message (`{source, sdk,
122    /// manifest}`), its protocol messages going to `sink`: run the program
123    /// until it waits on the host (`Some`) or ends (`None`; its `done` sent).
124    pub fn begin(start: &str, sink: Box<dyn Fn(&Value)>) -> Option<Realm> {
125        SINK.with(|s| *s.borrow_mut() = Some(sink));
126        let start: Value = match serde_json::from_str(start) {
127            Ok(v) => v,
128            Err(e) => {
129                emit(&done_error("internal_error", format!("bad start message: {e}")));
130                return None;
131            }
132        };
133        let field = |k: &str| start.get(k).and_then(Value::as_str).unwrap_or_default().to_string();
134        let (source, sdk, manifest) = (field("source"), field("sdk"), field("manifest"));
135        let loader = Rc::new(OnlySdk { sdk, parsed: RefCell::new(None) });
136        let mut cx = match Context::builder().module_loader(loader).build() {
137            Ok(cx) => cx,
138            Err(e) => {
139                emit(&done_error("internal_error", e.to_string()));
140                return None;
141            }
142        };
143        cx.runtime_limits_mut().set_loop_iteration_limit(crate::LOOP_ITERATION_LIMIT);
144        cx.runtime_limits_mut().set_recursion_limit(crate::RECURSION_LIMIT);
145        let logs: Logs = Rc::new(RefCell::new(Vec::new()));
146        let setup = (|| -> JsResult<()> {
147            install_console(&mut cx, &logs)?;
148            cx.register_global_property(js_string!("__brep_manifest"), js_string!(manifest), boa_engine::property::Attribute::all())?;
149            cx.register_global_builtin_callable(js_string!("__brep_host"), 2, NativeFunction::from_fn_ptr(host_call))?;
150            Ok(())
151        })();
152        if let Err(e) = setup {
153            let line = done_error("internal_error", describe(e, &mut cx));
154            return Self::end(&logs, &mut 0, line);
155        }
156        let mut sent = 0;
157        match Self::start_main(&source, &mut cx) {
158            Ok(main) => {
159                let mut realm = Realm { cx, logs, sent, main };
160                if realm.progress() {
161                    None
162                } else {
163                    Some(realm)
164                }
165            }
166            Err(line) => Self::end(&logs, &mut sent, line),
167        }
168    }
169
170    /// Evaluate the module and call its `main`: its Promise, or the `done`
171    /// line when it cannot start.
172    fn start_main(source: &str, cx: &mut Context) -> Result<JsPromise, Value> {
173        let module = Module::parse(Source::from_bytes(source).with_path(std::path::Path::new("main.js")), None, cx).map_err(|e| fail("compile_failed", e, cx))?;
174        let evaluated = module.load_link_evaluate(cx);
175        cx.run_jobs().map_err(|e| fail("job_failed", e, cx))?;
176        match evaluated.state() {
177            PromiseState::Fulfilled(_) => {}
178            PromiseState::Rejected(v) => {
179                // A link failure (a refused import) or a top-level throw.
180                let message = describe(JsError::from_opaque(v), cx);
181                let code = if message.contains("cannot import") || message.contains("SyntaxError") { "compile_failed" } else { "script_failed" };
182                return Err(done_error(code, message));
183            }
184            PromiseState::Pending => {
185                return Err(done_error("invalid_arguments", "module evaluation is pending without progress: top-level code may not wait on the host".into()))
186            }
187        }
188        let main = module.namespace(cx).get(js_string!("default"), cx).map_err(|e| fail("script_failed", e, cx))?;
189        let Some(main) = main.as_callable() else {
190            return Err(done_error("invalid_arguments", "the program must `export default async function main()`".into()));
191        };
192        let returned = main.call(&JsValue::undefined(), &[], cx).map_err(|e| fail("script_failed", e, cx))?;
193        returned
194            .as_object()
195            .and_then(|o| JsPromise::from_object(o.clone()).ok())
196            .ok_or_else(|| done_error("invalid_arguments", "main() must be async (return a Promise)".into()))
197    }
198
199    /// Deliver one host message (`{reply, body}`) and run on: whether the
200    /// execution has ended (its `done` sent).
201    pub fn answer(&mut self, message: &str) -> bool {
202        let Ok(msg) = serde_json::from_str::<Value>(message) else { return false };
203        let (Some(id), Some(body)) = (msg.get("reply").and_then(Value::as_u64), msg.get("body").and_then(Value::as_str)) else { return false };
204        if let Some((resolve, _)) = PENDING.with(|p| p.borrow_mut().remove(&id)) {
205            if let Err(e) = resolve.call(&JsValue::undefined(), &[js_string!(body).into()], &mut self.cx) {
206                let line = fail("job_failed", e, &mut self.cx);
207                Self::end(&self.logs, &mut self.sent, line);
208                return true;
209            }
210        }
211        self.progress()
212    }
213
214    /// The host closed the bridge with the execution still waiting.
215    pub fn abandon(&mut self) {
216        Self::end(&self.logs, &mut self.sent, done_error("internal_error", "the host closed the bridge".into()));
217    }
218
219    /// Run every job, then end if `main` settled or nothing can progress:
220    /// whether it ended.
221    fn progress(&mut self) -> bool {
222        if let Err(e) = self.cx.run_jobs() {
223            let line = fail("job_failed", e, &mut self.cx);
224            Self::end(&self.logs, &mut self.sent, line);
225            return true;
226        }
227        flush_logs(&self.logs, &mut self.sent);
228        let line = match self.main.state() {
229            PromiseState::Fulfilled(v) => match v.to_json(&mut self.cx) {
230                Ok(json) => json!({ "done": { "ok": json.unwrap_or(Value::Null) } }),
231                Err(e) => done_error("invalid_arguments", format!("main's result is not JSON: {}", describe(e, &mut self.cx))),
232            },
233            PromiseState::Rejected(v) => {
234                let message = describe(JsError::from_opaque(v), &mut self.cx);
235                done_error(if message.contains("RuntimeLimitError") { "resource_limit" } else { "script_failed" }, message)
236            }
237            PromiseState::Pending if PENDING.with(|p| p.borrow().is_empty()) => {
238                done_error("invalid_arguments", "main is pending without progress: no host call is outstanding and no job can run".into())
239            }
240            // Park on the host: nothing can run until a reply arrives.
241            PromiseState::Pending => return false,
242        };
243        Self::end(&self.logs, &mut self.sent, line);
244        true
245    }
246
247    /// Send the remaining logs and the `done` line; drop unsettled host calls
248    /// (they hold this realm's functions).
249    fn end(logs: &Logs, sent: &mut usize, line: Value) -> Option<Realm> {
250        flush_logs(logs, sent);
251        emit(&line);
252        PENDING.with(|p| p.borrow_mut().clear());
253        None
254    }
255}
256
257fn flush_logs(logs: &Logs, sent: &mut usize) {
258    let lines = logs.borrow();
259    for line in &lines[*sent..] {
260        emit(&json!({ "log": line }));
261    }
262    *sent = lines.len();
263}
264
265/// Run one execution reading stdin and writing stdout; the process exits
266/// after the `done` line.
267#[cfg(not(target_arch = "wasm32"))]
268pub fn main() {
269    let stdin = std::io::stdin();
270    let mut lines = stdin.lock().lines();
271    let Some(Ok(first)) = lines.next() else { return };
272    let sink = Box::new(|line: &Value| {
273        let mut out = std::io::stdout().lock();
274        let _ = writeln!(out, "{line}");
275        let _ = out.flush();
276    });
277    let Some(mut realm) = Realm::begin(&first, sink) else { return };
278    loop {
279        let Some(Ok(line)) = lines.next() else {
280            realm.abandon();
281            return;
282        };
283        if realm.answer(&line) {
284            return;
285        }
286    }
287}