Skip to main content

netget/scripting/
resident.rs

1//! Resident (long-lived) script handlers.
2//!
3//! See `src/scripting/CLAUDE.md` for the full model. In short: the default
4//! script path ([`super::executor`]) spawns a fresh interpreter **per event**,
5//! so a script can keep no state between events and pays interpreter start-up
6//! (and, for Go, a full compile) every time. A *resident* script is spawned
7//! **once per scope** and then driven with one event per line on its stdin,
8//! replying with one JSON line of actions per event. Because the process stays
9//! alive, module-level variables persist across events — counters, a parsed
10//! config, a connection map — with no re-reading.
11//!
12//! Resident mode is **opt-in** (`"resident": true` on a `script` event handler);
13//! the stateless per-event path is unchanged and remains the default.
14//!
15//! # Trust boundary
16//!
17//! Resident scripts run under the **same unsandboxed trust boundary** as
18//! per-event scripts (see the banner in `src/scripting/CLAUDE.md`): the
19//! interpreter runs as the netget user with full privileges. Resident mode adds
20//! no new capability to the script itself — it only keeps the same interpreter
21//! process alive between events instead of respawning it. The one operational
22//! difference worth stating: a resident process outlives a single event, so it
23//! is cleaned up on scope shutdown / idle eviction rather than by process exit.
24//!
25//! # Wire protocol (newline-delimited JSON, one round-trip per event)
26//!
27//! ```text
28//! parent  ──►  {"event_type_id": "...", "server": {...}, "connection": {...}, "event": {...}}\n
29//! child   ──►  {"actions": [ {...}, ... ]}\n
30//! ```
31//!
32//! The child harness (built here per language) wraps the user's code, which must
33//! define a `handle(event_type, event, message)` function. The harness reads a
34//! line, calls `handle`, and writes exactly one JSON line back. A user `handle`
35//! that raises emits `{"error": "..."}`, which the parent treats as a failed
36//! event (→ caller falls back to the LLM) — distinct from a legitimate empty
37//! `{"actions": []}`.
38//!
39//! # Robustness
40//!
41//! Every event round-trip runs under a [`tokio::time::timeout`]. If the resident
42//! wedges on one event or its process has died (EOF on stdout), the round-trip
43//! returns `Err`, the process is killed and evicted from the registry, and the
44//! caller falls back to the LLM. The next event for that scope spawns a fresh
45//! process. Nothing parks an OS thread: the child is spawned with
46//! [`tokio::process`] and awaited asynchronously.
47
48use super::environment::ScriptingEnvironment;
49use super::process_io::{read_line_bounded, ProcessGroup, MAX_STDERR_BYTES, MAX_STDOUT_BYTES};
50use super::types::{
51    parse_script_response, ScriptConfig, ScriptInput, ScriptLanguage, ScriptResponse,
52};
53use crate::utils::clock::Instant;
54use anyhow::{anyhow, Context as AnyhowContext, Result};
55use once_cell::sync::Lazy;
56use std::collections::HashMap;
57use std::hash::{Hash, Hasher};
58use std::process::Stdio;
59use std::sync::Arc;
60use std::time::Duration;
61use tokio::io::{AsyncReadExt, AsyncWriteExt, BufReader};
62use tokio::process::{Child, ChildStdin, ChildStdout, Command};
63use tokio::sync::Mutex;
64use tracing::{debug, info, warn};
65
66/// A resident process that has not been used for this long is killed and evicted
67/// on the next registry access. This bounds the lifetime of connection-scoped
68/// residents even when the owning connection closes through a path that cannot
69/// call [`shutdown_connection`] directly.
70const IDLE_TTL: Duration = Duration::from_secs(300);
71
72/// Scope that decides which events share one resident process (and therefore
73/// share in-process state). Mirrors the scheduled-task scopes in the root
74/// `CLAUDE.md`.
75#[derive(Clone, Copy, Debug, PartialEq, Eq)]
76pub enum ResidentScope {
77    /// One process per server: every connection's events for this handler share
78    /// the same process and state (e.g. a server-wide counter).
79    Server,
80    /// One process per connection: each connection gets an independent process
81    /// and independent state. Falls back to server scope for connectionless
82    /// events (no connection id to key on).
83    Connection,
84}
85
86impl ResidentScope {
87    /// Parse a scope string (`"server"` / `"connection"`); anything else, or
88    /// `None`, defaults to [`ResidentScope::Server`].
89    pub fn parse(s: Option<&str>) -> Self {
90        match s.map(|s| s.to_ascii_lowercase()).as_deref() {
91            Some("connection") | Some("conn") => ResidentScope::Connection,
92            _ => ResidentScope::Server,
93        }
94    }
95}
96
97/// Is this language supported in resident mode?
98///
99/// Python, JavaScript and Perl run a persistent stdin read-loop cheaply. Go is
100/// **not** supported: it is compiled per `go run` invocation and has no cheap
101/// persistent-interpreter form, so a resident Go handler transparently falls
102/// back to the per-event executor.
103pub fn resident_language_supported(language: ScriptLanguage) -> bool {
104    matches!(
105        language,
106        ScriptLanguage::Python | ScriptLanguage::JavaScript | ScriptLanguage::Perl
107    )
108}
109
110/// Key identifying one resident process. Two handlers with identical code and
111/// scope share a process; different code (or a different connection under
112/// connection scope) gets its own.
113#[derive(Clone, Debug, PartialEq, Eq, Hash)]
114struct ScopeKey {
115    /// Server or client id, per `owner_is_client`. Ids are unified process-wide
116    /// (one counter allocates both), but the discriminator keeps ownership
117    /// explicit rather than resting on that invariant.
118    owner_id: u32,
119    owner_is_client: bool,
120    /// `Some` only under connection scope with a connection present.
121    connection_id: Option<String>,
122    language: ScriptLanguage,
123    code_hash: u64,
124    private_payloads: bool,
125}
126
127impl ScopeKey {
128    fn new(
129        scope: ResidentScope,
130        input: &ScriptInput,
131        language: ScriptLanguage,
132        code: &str,
133    ) -> Self {
134        let connection_id = match scope {
135            ResidentScope::Connection => input.connection.as_ref().map(|c| c.id.clone()),
136            ResidentScope::Server => None,
137        };
138        let mut hasher = std::collections::hash_map::DefaultHasher::new();
139        language.as_str().hash(&mut hasher);
140        code.hash(&mut hasher);
141        // For clients, `Server` and `Connection` scope coincide (one connection
142        // per client) — both key on the client id alone.
143        let (owner_id, owner_is_client) = match (&input.server, &input.client) {
144            (Some(server), _) => (server.id, false),
145            (None, Some(client)) => (client.id, true),
146            (None, None) => (0, false),
147        };
148        Self {
149            owner_id,
150            owner_is_client,
151            connection_id,
152            language,
153            code_hash: hasher.finish(),
154            private_payloads: false,
155        }
156    }
157
158    fn describe(&self) -> String {
159        let owner = if self.owner_is_client {
160            format!("client #{}", self.owner_id)
161        } else {
162            format!("server #{}", self.owner_id)
163        };
164        match &self.connection_id {
165            Some(c) => format!("{} conn {} [{}]", owner, c, self.language.as_str()),
166            None => format!("{} [{}]", owner, self.language.as_str()),
167        }
168    }
169}
170
171/// The live pipes of a resident process. `None` in [`ResidentScript::io`] once
172/// the process is dead so a later round-trip fails fast instead of writing to a
173/// broken pipe.
174struct ResidentIo {
175    group: ProcessGroup,
176    stderr_task: tokio::task::JoinHandle<()>,
177    child: Child,
178    stdin: ChildStdin,
179    stdout: BufReader<ChildStdout>,
180}
181
182impl Drop for ResidentIo {
183    fn drop(&mut self) {
184        self.group.kill();
185        self.stderr_task.abort();
186    }
187}
188
189/// A single resident process. Access to its pipes is serialized by the `io`
190/// mutex — one event round-trip at a time per process, which is required (two
191/// concurrent writers on one stdin would interleave) and mirrors the
192/// per-connection state machine used elsewhere.
193pub struct ResidentScript {
194    language: ScriptLanguage,
195    io: Mutex<Option<ResidentIo>>,
196    describe: String,
197}
198
199impl ResidentScript {
200    /// Spawn the interpreter with the wrapped resident harness. Synchronous
201    /// (`.spawn()` does not await), so it is safe to call while holding the
202    /// registry lock.
203    fn spawn(
204        language: ScriptLanguage,
205        code: &str,
206        describe: String,
207        private_payloads: bool,
208    ) -> Result<Arc<Self>> {
209        let (command, args, wrapped): (&str, Vec<&str>, String) = match language {
210            ScriptLanguage::Python => ("python3", vec!["-u", "-c"], python_harness(code)),
211            ScriptLanguage::JavaScript => ("node", vec!["-e"], javascript_harness(code)),
212            ScriptLanguage::Perl => ("perl", vec!["-e"], perl_harness(code)),
213            ScriptLanguage::Go => {
214                anyhow::bail!("Go is not supported in resident script mode");
215            }
216        };
217
218        let mut builder = Command::new(command);
219        builder.args(&args);
220        builder.arg(&wrapped);
221        builder
222            .stdin(Stdio::piped())
223            .stdout(Stdio::piped())
224            .stderr(Stdio::piped())
225            // If the last Arc is dropped (scope shutdown, idle eviction, task
226            // cancellation) make sure the interpreter dies with it.
227            .kill_on_drop(true);
228
229        ProcessGroup::configure(&mut builder);
230        let mut child = builder
231            .spawn()
232            .map_err(|e| spawn_error(language, command, e))?;
233        let group = ProcessGroup::new(&child)?;
234
235        let stdin = child
236            .stdin
237            .take()
238            .context("resident: failed to capture stdin")?;
239        let stdout = BufReader::new(
240            child
241                .stdout
242                .take()
243                .context("resident: failed to capture stdout")?,
244        );
245        let stderr = child
246            .stderr
247            .take()
248            .context("resident: failed to capture stderr")?;
249
250        // Drain stderr in the background so the child never blocks on a full
251        // stderr pipe, and so diagnostics reach the log.
252        let describe_for_log = describe.clone();
253        let stderr_task = tokio::spawn(async move {
254            let mut stderr = stderr;
255            let mut chunk = vec![0; 8192];
256            let mut logged = 0usize;
257            while let Ok(n) = stderr.read(&mut chunk).await {
258                if n == 0 {
259                    break;
260                }
261                let keep = n.min(MAX_STDERR_BYTES.saturating_sub(logged));
262                if keep > 0 {
263                    let shown = if private_payloads {
264                        std::borrow::Cow::Borrowed(crate::utils::redact::REDACTED)
265                    } else {
266                        String::from_utf8_lossy(&chunk[..keep])
267                    };
268                    warn!("resident script {}: {}", describe_for_log, shown);
269                    logged += keep;
270                    if logged == MAX_STDERR_BYTES {
271                        warn!("resident script {}: stderr log cap reached; draining further diagnostics without logging", describe_for_log);
272                    }
273                }
274            }
275        });
276
277        info!(
278            "Spawned resident {} script ({})",
279            language.as_str(),
280            describe
281        );
282
283        Ok(Arc::new(Self {
284            language,
285            io: Mutex::new(Some(ResidentIo {
286                group,
287                stderr_task,
288                child,
289                stdin,
290                stdout,
291            })),
292            describe,
293        }))
294    }
295
296    /// Send one event and read one response, under `timeout`. On any failure the
297    /// process is killed and marked dead (`io` set to `None`), so the caller can
298    /// evict it and the next event respawns.
299    async fn round_trip(&self, input: &ScriptInput, timeout: Duration) -> Result<ScriptResponse> {
300        let deadline = tokio::time::Instant::now()
301            .checked_add(timeout)
302            .ok_or_else(|| {
303                anyhow!(
304                    "resident script timeout {:?} exceeds the clock range",
305                    timeout
306                )
307            })?;
308        let mut guard = tokio::time::timeout_at(deadline, self.io.lock())
309            .await
310            .map_err(|_| {
311                anyhow!(
312                    "resident script event exceeded {:?} timeout waiting for its turn",
313                    timeout
314                )
315            })?;
316        // The interaction owns the child while in flight. If this future is cancelled,
317        // kill_on_drop terminates it and the slot stays empty; its unread reply cannot
318        // be mistaken for the next event's response.
319        let mut io = guard
320            .take()
321            .ok_or_else(|| anyhow!("resident script process is not alive"))?;
322
323        // One newline-delimited JSON request. `to_string` (not pretty) keeps it
324        // to a single line, which the child's readline-per-event loop requires.
325        let mut line = serde_json::to_string(input).context("resident: serialize event")?;
326        line.push('\n');
327
328        let result: Result<String> = {
329            let interaction = async {
330                io.stdin
331                    .write_all(line.as_bytes())
332                    .await
333                    .context("resident: write event to stdin")?;
334                io.stdin.flush().await.context("resident: flush stdin")?;
335                read_line_bounded(&mut io.stdout, MAX_STDOUT_BYTES)
336                    .await
337                    .context("resident: read response line")
338            };
339            match tokio::time::timeout_at(deadline, interaction).await {
340                Ok(inner) => inner,
341                Err(_) => Err(anyhow!(
342                    "resident script event exceeded {:?} timeout",
343                    timeout
344                )),
345            }
346        };
347
348        match result {
349            Ok(response) => {
350                *guard = Some(io);
351                // A `{"error": ...}` reply (a `handle()` exception in the child)
352                // has no `actions` field, so parsing fails here and the caller
353                // falls back to the LLM — while the process stays alive for the
354                // next event.
355                parse_script_response(response.trim())
356                    .with_context(|| format!("resident script {} response", self.describe))
357            }
358            Err(e) => {
359                // Kill and mark dead so a wedged/broken process is not reused.
360                io.group.kill();
361                let _ = io.child.start_kill();
362                let _ = tokio::time::timeout(Duration::from_secs(2), io.child.wait()).await;
363                warn!("resident script {} failed: {}", self.describe, e);
364                Err(e)
365            }
366        }
367    }
368
369    /// Kill this process now (used by scope shutdown). Idempotent.
370    async fn kill(&self) {
371        let mut guard = self.io.lock().await;
372        if let Some(mut io) = guard.take() {
373            io.group.kill();
374            let _ = io.child.start_kill();
375            let _ = tokio::time::timeout(Duration::from_secs(2), io.child.wait()).await;
376            debug!("Killed resident script {}", self.describe);
377        }
378    }
379
380    /// Whether the process is dead (pipes absent). A per-event failure
381    /// such as a `handle()` exception leaves the process alive; a wedge or crash
382    /// marks it dead (`io == None`).
383    fn is_dead(&self) -> bool {
384        // An occupied lock means another event is still using a live process. Never
385        // wait here: a request which expired in the queue must return promptly.
386        self.io.try_lock().is_ok_and(|io| io.is_none())
387    }
388
389    /// Language this resident runs (for diagnostics/tests).
390    pub fn language(&self) -> ScriptLanguage {
391        self.language
392    }
393}
394
395/// One registry slot: the shared process plus its last-use time for idle
396/// eviction.
397struct ResidentEntry {
398    script: Arc<ResidentScript>,
399    last_used: Instant,
400}
401
402/// Process-global registry of resident scripts, keyed by scope+code. A single
403/// mutex guards the map; it is held only for lookup/insert/evict (no `.await`
404/// across the round-trip), so it is not on the hot path the way `AppState` is.
405static REGISTRY: Lazy<Mutex<HashMap<ScopeKey, ResidentEntry>>> =
406    Lazy::new(|| Mutex::new(HashMap::new()));
407
408/// Manager for resident scripts. All methods are static; state lives in the
409/// process-global [`REGISTRY`].
410pub struct ResidentScriptManager;
411
412impl ResidentScriptManager {
413    /// Dispatch one event to the resident process for `(scope, code)`, spawning
414    /// it on first use. Returns the script's actions, or `Err` if the process
415    /// could not be reached (dead, wedged, or unsupported language) — in which
416    /// case the caller should fall back to the LLM.
417    ///
418    /// The default budget is [`super::executor::DEFAULT_SCRIPT_TIMEOUT`]; use
419    /// [`dispatch_with_timeout`](Self::dispatch_with_timeout) for a custom one.
420    pub async fn dispatch(
421        config: &ScriptConfig,
422        input: &ScriptInput,
423        scope: ResidentScope,
424    ) -> Result<ScriptResponse> {
425        Self::dispatch_with_timeout(
426            config,
427            input,
428            scope,
429            super::executor::DEFAULT_SCRIPT_TIMEOUT,
430        )
431        .await
432    }
433
434    /// Credential-bearing callers hide raw diagnostics and keep a separate process
435    /// from an ordinary invocation of the same code and scope.
436    pub(crate) async fn dispatch_private(
437        config: &ScriptConfig,
438        input: &ScriptInput,
439        scope: ResidentScope,
440    ) -> Result<ScriptResponse> {
441        Self::dispatch_with_timeout_and_privacy(
442            config,
443            input,
444            scope,
445            super::executor::DEFAULT_SCRIPT_TIMEOUT,
446            true,
447        )
448        .await
449    }
450
451    /// [`dispatch`](Self::dispatch) with an explicit per-event timeout.
452    pub async fn dispatch_with_timeout(
453        config: &ScriptConfig,
454        input: &ScriptInput,
455        scope: ResidentScope,
456        timeout: Duration,
457    ) -> Result<ScriptResponse> {
458        Self::dispatch_with_timeout_and_privacy(config, input, scope, timeout, false).await
459    }
460
461    async fn dispatch_with_timeout_and_privacy(
462        config: &ScriptConfig,
463        input: &ScriptInput,
464        scope: ResidentScope,
465        timeout: Duration,
466        private_payloads: bool,
467    ) -> Result<ScriptResponse> {
468        if !resident_language_supported(config.language) {
469            anyhow::bail!(
470                "language '{}' is not supported in resident mode",
471                config.language.as_str()
472            );
473        }
474        let deadline = tokio::time::Instant::now()
475            .checked_add(timeout)
476            .ok_or_else(|| {
477                anyhow!(
478                    "resident script timeout {:?} exceeds the clock range",
479                    timeout
480                )
481            })?;
482        let code = tokio::time::timeout_at(deadline, config.source.get_code_async())
483            .await
484            .context("resident: timed out loading script code")?
485            .context("resident: failed to load script code")?;
486        let mut key = ScopeKey::new(scope, input, config.language, &code);
487        key.private_payloads = private_payloads;
488
489        // Acquire (or spawn) the resident process. The registry lock is held only
490        // for this block — spawning does not await — and dropped before the
491        // round-trip, so a slow event never blocks other scopes' lookups.
492        let script = {
493            let mut registry = tokio::time::timeout_at(deadline, REGISTRY.lock())
494                .await
495                .context("resident: timed out waiting for registry")?;
496            evict_idle(&mut registry);
497            if registry
498                .get(&key)
499                .is_some_and(|entry| entry.script.is_dead())
500            {
501                registry.remove(&key);
502            }
503            match registry.get_mut(&key) {
504                Some(entry) => {
505                    entry.last_used = Instant::now();
506                    entry.script.clone()
507                }
508                None => {
509                    let script = ResidentScript::spawn(
510                        config.language,
511                        &code,
512                        key.describe(),
513                        private_payloads,
514                    )?;
515                    registry.insert(
516                        key.clone(),
517                        ResidentEntry {
518                            script: script.clone(),
519                            last_used: Instant::now(),
520                        },
521                    );
522                    script
523                }
524            }
525        };
526
527        match script
528            .round_trip(
529                input,
530                deadline.saturating_duration_since(tokio::time::Instant::now()),
531            )
532            .await
533        {
534            Ok(response) => {
535                debug!(
536                    "resident script {} handled '{}' ({} actions)",
537                    key.describe(),
538                    input.event_type_id,
539                    response.actions.len()
540                );
541                Ok(response)
542            }
543            Err(e) => {
544                // Distinguish a dead/wedged process (evict so the next event
545                // respawns) from a live process that merely failed this one event
546                // — a `handle()` exception, or an unparseable reply. The latter
547                // must stay resident so its in-process state survives; only the
548                // failed event is deferred to the LLM.
549                if script.is_dead() {
550                    let mut registry = REGISTRY.lock().await;
551                    if let Some(entry) = registry.get(&key) {
552                        if Arc::ptr_eq(&entry.script, &script) {
553                            registry.remove(&key);
554                        }
555                    }
556                }
557                Err(e)
558            }
559        }
560    }
561
562    /// Kill and evict every resident process for a server (all scopes,
563    /// connection- and server-scoped). Call from the server-close path. Returns
564    /// how many processes were shut down.
565    pub async fn shutdown_server(server_id: u32) -> usize {
566        let count = Self::shutdown_owner(server_id, false).await;
567        if count > 0 {
568            info!(
569                "Shut down {} resident script(s) for server #{}",
570                count, server_id
571            );
572        }
573        count
574    }
575
576    /// Kill and evict every resident process for a client. Call from the
577    /// client-removal path. Returns how many processes were shut down.
578    pub async fn shutdown_client(client_id: u32) -> usize {
579        let count = Self::shutdown_owner(client_id, true).await;
580        if count > 0 {
581            info!(
582                "Shut down {} resident script(s) for client #{}",
583                count, client_id
584            );
585        }
586        count
587    }
588
589    async fn shutdown_owner(owner_id: u32, owner_is_client: bool) -> usize {
590        let scripts = {
591            let mut registry = REGISTRY.lock().await;
592            let keys: Vec<ScopeKey> = registry
593                .keys()
594                .filter(|k| k.owner_id == owner_id && k.owner_is_client == owner_is_client)
595                .cloned()
596                .collect();
597            keys.into_iter()
598                .filter_map(|k| registry.remove(&k).map(|e| e.script))
599                .collect::<Vec<_>>()
600        };
601        let count = scripts.len();
602        for script in scripts {
603            script.kill().await;
604        }
605        count
606    }
607
608    /// Kill and evict the connection-scoped resident(s) for one connection.
609    pub async fn shutdown_connection(server_id: u32, connection_id: &str) -> usize {
610        let scripts = {
611            let mut registry = REGISTRY.lock().await;
612            let keys: Vec<ScopeKey> = registry
613                .keys()
614                .filter(|k| {
615                    k.owner_id == server_id
616                        && !k.owner_is_client
617                        && k.connection_id.as_deref() == Some(connection_id)
618                })
619                .cloned()
620                .collect();
621            keys.into_iter()
622                .filter_map(|k| registry.remove(&k).map(|e| e.script))
623                .collect::<Vec<_>>()
624        };
625        let count = scripts.len();
626        for script in scripts {
627            script.kill().await;
628        }
629        count
630    }
631
632    /// Kill and evict every resident process (all servers). Returns the count.
633    pub async fn shutdown_all() -> usize {
634        let scripts = {
635            let mut registry = REGISTRY.lock().await;
636            registry.drain().map(|(_, e)| e.script).collect::<Vec<_>>()
637        };
638        let count = scripts.len();
639        for script in scripts {
640            script.kill().await;
641        }
642        count
643    }
644
645    /// Number of live resident processes. Intended for tests and diagnostics.
646    pub async fn active_count() -> usize {
647        REGISTRY.lock().await.len()
648    }
649}
650
651/// Remove (and, via drop + `kill_on_drop`, kill) entries idle longer than
652/// [`IDLE_TTL`]. Called under the registry lock on every dispatch.
653fn evict_idle(registry: &mut HashMap<ScopeKey, ResidentEntry>) {
654    let now = Instant::now();
655    let stale: Vec<ScopeKey> = registry
656        .iter()
657        .filter(|(_, e)| now.duration_since(e.last_used) > IDLE_TTL)
658        .map(|(k, _)| k.clone())
659        .collect();
660    for key in stale {
661        debug!("Evicting idle resident script {}", key.describe());
662        registry.remove(&key);
663    }
664}
665
666/// Whether the interpreter for a resident language is installed. Mirrors the
667/// per-event executor's availability check so a resident handler for a missing
668/// interpreter falls back to the LLM rather than erroring per event.
669pub fn resident_available(env: &ScriptingEnvironment, language: ScriptLanguage) -> bool {
670    resident_language_supported(language) && env.is_available(language)
671}
672
673// ---------------------------------------------------------------------------
674// Harness wrappers: the persistent read-loop each language runs around the
675// user's `handle(event_type, event, message)` function.
676// ---------------------------------------------------------------------------
677
678/// Python resident harness. Runs under `python3 -u` (unbuffered). The user code
679/// must define `handle(event_type, event, message)`.
680fn python_harness(user_code: &str) -> String {
681    format!(
682        r#"import sys, json, traceback
683
684def _ng_emit(obj):
685    sys.stdout.write(json.dumps(obj) + "\n")
686    sys.stdout.flush()
687
688# ===== user code: must define handle(event_type, event, message) =====
689{user_code}
690# ===== end user code =====
691
692for _ng_line in sys.stdin:
693    _ng_line = _ng_line.strip()
694    if not _ng_line:
695        continue
696    try:
697        _ng_msg = json.loads(_ng_line)
698    except Exception as _ng_err:
699        sys.stderr.write("resident: could not parse input line: %s\n" % _ng_err)
700        _ng_emit({{"error": "bad input line"}})
701        continue
702    try:
703        _ng_result = handle(_ng_msg.get("event_type_id"), _ng_msg.get("event"), _ng_msg)
704    except Exception:
705        sys.stderr.write("resident handle() raised:\n%s" % traceback.format_exc())
706        _ng_emit({{"error": "handle() raised"}})
707        continue
708    if isinstance(_ng_result, dict) and "actions" in _ng_result:
709        _ng_emit(_ng_result)
710    elif isinstance(_ng_result, list):
711        _ng_emit({{"actions": _ng_result}})
712    elif _ng_result is None:
713        _ng_emit({{"actions": []}})
714    else:
715        sys.stderr.write("resident handle() returned unexpected type; expected list or dict\n")
716        _ng_emit({{"error": "handle() returned an unexpected type"}})
717"#,
718        user_code = user_code
719    )
720}
721
722/// JavaScript (Node.js) resident harness. The user code must define
723/// `function handle(event_type, event, message)`.
724fn javascript_harness(user_code: &str) -> String {
725    format!(
726        r#"const readline = require('readline');
727function _ngEmit(obj) {{
728    process.stdout.write(JSON.stringify(obj) + "\n");
729}}
730
731// ===== user code: must define handle(event_type, event, message) =====
732{user_code}
733// ===== end user code =====
734
735const _ngRl = readline.createInterface({{ input: process.stdin }});
736_ngRl.on('line', async (line) => {{
737    line = line.trim();
738    if (!line) return;
739    let msg;
740    try {{
741        msg = JSON.parse(line);
742    }} catch (e) {{
743        process.stderr.write("resident: could not parse input line: " + e + "\n");
744        _ngEmit({{ error: "bad input line" }});
745        return;
746    }}
747    let result;
748    try {{
749        result = await handle(msg.event_type_id, msg.event, msg);
750    }} catch (e) {{
751        process.stderr.write("resident handle() raised: " + (e && e.stack ? e.stack : e) + "\n");
752        _ngEmit({{ error: "handle() raised" }});
753        return;
754    }}
755    if (Array.isArray(result)) {{
756        _ngEmit({{ actions: result }});
757    }} else if (result && typeof result === 'object' && 'actions' in result) {{
758        _ngEmit(result);
759    }} else if (result === undefined || result === null) {{
760        _ngEmit({{ actions: [] }});
761    }} else {{
762        process.stderr.write("resident handle() returned unexpected type; expected array or object\n");
763        _ngEmit({{ error: "handle() returned an unexpected type" }});
764    }}
765}});
766"#,
767        user_code = user_code
768    )
769}
770
771/// Perl resident harness. Uses `JSON::PP` (core since Perl 5.14, no CPAN
772/// dependency). The user code must define `sub handle {{ my ($event_type,
773/// $event, $message) = @_; ... }}` returning an array-ref or hash-ref.
774fn perl_harness(user_code: &str) -> String {
775    format!(
776        r#"use strict;
777use warnings;
778use JSON::PP;
779$| = 1;
780my $_ng_json = JSON::PP->new->utf8->canonical;
781
782sub _ng_emit {{
783    my ($obj) = @_;
784    print $_ng_json->encode($obj) . "\n";
785}}
786
787# ===== user code: must define sub handle($event_type, $event, $message) =====
788{user_code}
789# ===== end user code =====
790
791while (my $_ng_line = <STDIN>) {{
792    $_ng_line =~ s/^\s+|\s+$//g;
793    next if $_ng_line eq '';
794    my $_ng_msg = eval {{ $_ng_json->decode($_ng_line) }};
795    if ($@) {{
796        print STDERR "resident: could not parse input line: $@\n";
797        _ng_emit({{ error => "bad input line" }});
798        next;
799    }}
800    my $_ng_result = eval {{ handle($_ng_msg->{{event_type_id}}, $_ng_msg->{{event}}, $_ng_msg) }};
801    if ($@) {{
802        print STDERR "resident handle() raised: $@\n";
803        _ng_emit({{ error => "handle() raised" }});
804        next;
805    }}
806    if (ref($_ng_result) eq 'HASH' && exists $_ng_result->{{actions}}) {{
807        _ng_emit($_ng_result);
808    }} elsif (ref($_ng_result) eq 'ARRAY') {{
809        _ng_emit({{ actions => $_ng_result }});
810    }} elsif (!defined $_ng_result) {{
811        _ng_emit({{ actions => [] }});
812    }} else {{
813        print STDERR "resident handle() returned unexpected type; expected array-ref or hash-ref\n";
814        _ng_emit({{ error => "handle() returned an unexpected type" }});
815    }}
816}}
817"#,
818        user_code = user_code
819    )
820}
821
822/// Build an actionable error for a failed interpreter spawn (mirrors the
823/// per-event executor's message).
824fn spawn_error(language: ScriptLanguage, command: &str, err: std::io::Error) -> anyhow::Error {
825    if err.kind() == std::io::ErrorKind::NotFound {
826        anyhow!(
827            "Interpreter `{command}` for resident '{lang}' script was not found on PATH. \
828             Install it, or switch this handler to a language whose interpreter is installed.",
829            command = command,
830            lang = language.as_str(),
831        )
832    } else {
833        anyhow!(
834            "Failed to start interpreter `{command}` for resident '{lang}' script: {err}",
835            command = command,
836            lang = language.as_str(),
837            err = err,
838        )
839    }
840}