1use 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
66const IDLE_TTL: Duration = Duration::from_secs(300);
71
72#[derive(Clone, Copy, Debug, PartialEq, Eq)]
76pub enum ResidentScope {
77 Server,
80 Connection,
84}
85
86impl ResidentScope {
87 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
97pub fn resident_language_supported(language: ScriptLanguage) -> bool {
104 matches!(
105 language,
106 ScriptLanguage::Python | ScriptLanguage::JavaScript | ScriptLanguage::Perl
107 )
108}
109
110#[derive(Clone, Debug, PartialEq, Eq, Hash)]
114struct ScopeKey {
115 owner_id: u32,
119 owner_is_client: bool,
120 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 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
171struct 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
189pub struct ResidentScript {
194 language: ScriptLanguage,
195 io: Mutex<Option<ResidentIo>>,
196 describe: String,
197}
198
199impl ResidentScript {
200 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 .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 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 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 let mut io = guard
320 .take()
321 .ok_or_else(|| anyhow!("resident script process is not alive"))?;
322
323 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 parse_script_response(response.trim())
356 .with_context(|| format!("resident script {} response", self.describe))
357 }
358 Err(e) => {
359 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 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 fn is_dead(&self) -> bool {
384 self.io.try_lock().is_ok_and(|io| io.is_none())
387 }
388
389 pub fn language(&self) -> ScriptLanguage {
391 self.language
392 }
393}
394
395struct ResidentEntry {
398 script: Arc<ResidentScript>,
399 last_used: Instant,
400}
401
402static REGISTRY: Lazy<Mutex<HashMap<ScopeKey, ResidentEntry>>> =
406 Lazy::new(|| Mutex::new(HashMap::new()));
407
408pub struct ResidentScriptManager;
411
412impl ResidentScriptManager {
413 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 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 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 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 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 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 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 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 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 pub async fn active_count() -> usize {
647 REGISTRY.lock().await.len()
648 }
649}
650
651fn 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
666pub fn resident_available(env: &ScriptingEnvironment, language: ScriptLanguage) -> bool {
670 resident_language_supported(language) && env.is_available(language)
671}
672
673fn 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
722fn 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
771fn 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
822fn 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}