Skip to main content

mhome_script_runtime/
worker.rs

1use crate::{invoke, CancelOnDrop, CancellationToken, InvocationControl, ScriptHost, ScriptLimits};
2use mhome_script_api::{ScriptError, ScriptValue};
3use std::{
4    sync::{
5        atomic::{AtomicUsize, Ordering},
6        Arc, Mutex,
7    },
8    thread::JoinHandle,
9};
10use tokio::{
11    sync::{mpsc, oneshot, watch, OwnedSemaphorePermit, Semaphore},
12    task::{JoinSet, LocalSet},
13};
14
15/// A trusted host scheduling classification, never selected by script arguments.
16#[derive(Clone, Copy, Debug)]
17pub enum Admission {
18    Request,
19    Hook,
20}
21
22#[derive(Clone, Copy, Debug)]
23pub struct WorkerConfig {
24    pub threads: usize,
25    pub requests: usize,
26    pub hooks: usize,
27}
28
29impl Default for WorkerConfig {
30    fn default() -> Self {
31        Self {
32            threads: 2,
33            requests: 4,
34            hooks: 2,
35        }
36    }
37}
38
39struct Job {
40    source: Arc<str>,
41    args: Vec<ScriptValue>,
42    host: Arc<dyn ScriptHost>,
43    control: InvocationControl,
44    limits: ScriptLimits,
45    reply: oneshot::Sender<Result<ScriptValue, ScriptError>>,
46    _permit: OwnedSemaphorePermit,
47}
48
49struct Worker {
50    queue: mpsc::Sender<Job>,
51    finished: watch::Receiver<bool>,
52    thread: Mutex<Option<JoinHandle<()>>>,
53}
54
55struct Inner {
56    workers: Vec<Worker>,
57    requests: Arc<Semaphore>,
58    hooks: Arc<Semaphore>,
59    next: AtomicUsize,
60    shutdown: CancellationToken,
61}
62
63impl Drop for Inner {
64    fn drop(&mut self) {
65        self.shutdown.cancel();
66    }
67}
68
69/// A small dedicated CPU pool. Waiting host calls yield within a LocalSet;
70/// request saturation cannot consume the separate hook admission capacity.
71/// Admission is fail-fast, including hooks, so recursive hooks never wait forever
72/// for a slot held by their caller. Capacity is released after actual worker cleanup.
73#[derive(Clone)]
74pub struct ScriptWorkers(Arc<Inner>);
75
76impl ScriptWorkers {
77    /// Create at service startup and share across Integration and Automation calls.
78    pub fn new(config: WorkerConfig) -> Result<Self, ScriptError> {
79        if !(1..=64).contains(&config.threads) || config.requests == 0 || config.hooks == 0 {
80            return Err(ScriptError::new(
81                "INVALID_CONFIG",
82                "Script worker counts must be positive and threads at most 64",
83            ));
84        }
85        let capacity = config
86            .requests
87            .checked_add(config.hooks)
88            .filter(|n| *n <= Semaphore::MAX_PERMITS)
89            .ok_or_else(|| ScriptError::new("INVALID_CONFIG", "Script worker capacity overflow"))?;
90        let mut inner = Inner {
91            workers: Vec::new(),
92            requests: Arc::new(Semaphore::new(config.requests)),
93            hooks: Arc::new(Semaphore::new(config.hooks)),
94            next: AtomicUsize::new(0),
95            shutdown: CancellationToken::new(),
96        };
97        for index in 0..config.threads {
98            let runtime = tokio::runtime::Builder::new_current_thread()
99                .enable_all()
100                .build()
101                .map_err(|e| ScriptError::new("WORKER_START_FAILED", e.to_string()))?;
102            let (queue, receiver) = mpsc::channel(capacity);
103            let (finished_tx, finished) = watch::channel(false);
104            let shutdown = inner.shutdown.clone();
105            let thread = std::thread::Builder::new()
106                .name(format!("script-{index}"))
107                .spawn(move || {
108                    let _finished = Finished(finished_tx);
109                    // Drop the Tokio runtime (including its native blocking jobs)
110                    // before announcing completion. Otherwise shutdown's join can
111                    // block the caller executor after the JS tasks have exited.
112                    let worker_runtime = runtime;
113                    worker_runtime.block_on(LocalSet::new().run_until(worker(receiver, shutdown)));
114                })
115                .map_err(|e| ScriptError::new("WORKER_START_FAILED", e.to_string()))?;
116            inner.workers.push(Worker {
117                queue,
118                finished,
119                thread: Mutex::new(Some(thread)),
120            });
121        }
122        Ok(Self(Arc::new(inner)))
123    }
124
125    pub async fn invoke(
126        &self,
127        source: Arc<str>,
128        args: Vec<ScriptValue>,
129        host: Arc<dyn ScriptHost>,
130        mut control: InvocationControl,
131        limits: ScriptLimits,
132        admission: Admission,
133    ) -> Result<ScriptValue, ScriptError> {
134        control.shutdown = self.0.shutdown.clone();
135        control.check()?;
136        let permits = match admission {
137            Admission::Request => &self.0.requests,
138            Admission::Hook => &self.0.hooks,
139        };
140        let permit = permits.clone().try_acquire_owned().map_err(|_| {
141            ScriptError::new("SCRIPT_BUSY", "Script execution capacity is exhausted")
142        })?;
143        control.cancel = control.cancel.child_token();
144        let _cancel_on_drop = CancelOnDrop(control.cancel.clone());
145        let (reply, result) = oneshot::channel();
146        let job = Job {
147            source,
148            args,
149            host,
150            control: control.clone(),
151            limits,
152            reply,
153            _permit: permit,
154        };
155        let index = self.0.next.fetch_add(1, Ordering::Relaxed) % self.0.workers.len();
156        self.0.workers[index]
157            .queue
158            .try_send(job)
159            .map_err(|_| ScriptError::new("WORKER_UNAVAILABLE", "Script worker is unavailable"))?;
160        tokio::select! {
161            biased;
162            _ = control.cancelled() => Err(ScriptError::new("CANCELLED", "Script invocation cancelled")),
163            _ = tokio::time::sleep_until(control.deadline.into()) => Err(ScriptError::new("DEADLINE_EXCEEDED", "Script invocation timed out")),
164            result = result => result.unwrap_or_else(|_| Err(ScriptError::new("WORKER_FAILED", "Script worker failed"))),
165        }
166    }
167
168    /// Stop admission, interrupt CPU work, drop host futures and wait for threads.
169    /// Idempotent; closing any clone closes the shared service.
170    pub async fn shutdown(&self) {
171        self.0.shutdown.cancel();
172        for worker in &self.0.workers {
173            let mut finished = worker.finished.clone();
174            let _ = finished.wait_for(|done| *done).await;
175            // The completion guard has run; joining cannot wait for script work.
176            if let Some(thread) = worker
177                .thread
178                .lock()
179                .unwrap_or_else(|e| e.into_inner())
180                .take()
181            {
182                let _ = thread.join();
183            }
184        }
185    }
186}
187
188struct Finished(watch::Sender<bool>);
189impl Drop for Finished {
190    fn drop(&mut self) {
191        self.0.send_replace(true);
192    }
193}
194
195async fn worker(mut queue: mpsc::Receiver<Job>, shutdown: CancellationToken) {
196    let mut running = JoinSet::new();
197    loop {
198        tokio::select! {
199            biased;
200            _ = shutdown.cancelled() => break,
201            job = queue.recv() => match job {
202                Some(job) => {
203                    running.spawn_local(async move {
204                        let result = invoke(&job.source, job.args, job.host, job.control, job.limits).await;
205                        let _ = job.reply.send(result);
206                        // Permit lives through runtime teardown, including error/cancellation.
207                        drop(job._permit);
208                    });
209                }
210                None => break,
211            },
212            _ = running.join_next(), if !running.is_empty() => {},
213        }
214    }
215    queue.close();
216    while queue.try_recv().is_ok() {}
217    running.abort_all();
218    while running.join_next().await.is_some() {}
219}