1use std::collections::BTreeMap;
39use std::io::{Read, Write};
40use std::path::PathBuf;
41use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
42use std::sync::{Arc, LazyLock, Mutex, OnceLock};
43use std::time::Duration;
44
45use harn_vm::VmValue;
46
47use crate::error::HostlibError;
48use crate::json::vm_dict_to_json;
49use crate::process::{self as process_handle, ProcessHandle, ProcessKiller, SpawnSpec};
50use crate::tools::args::to_agent_path;
51use crate::tools::proc::{self, CaptureConfig, CommandStatus, EnvMode};
52
53static HANDLE_COUNTER: AtomicU64 = AtomicU64::new(1);
55
56struct CancelState {
58 cancelled: AtomicBool,
61 timed_out: AtomicBool,
65 process_cleanup: Mutex<Option<process_handle::ProcessCleanupReport>>,
67}
68
69#[derive(Default)]
70struct OutputState {
71 stdout: Vec<u8>,
72 stderr: Vec<u8>,
73}
74
75struct HandleEntry {
77 handle: Option<Box<dyn ProcessHandle>>,
79 killer: Arc<dyn ProcessKiller>,
81 session_id: String,
82 cancel_state: Arc<CancelState>,
84 completion_tx: Option<std::sync::mpsc::SyncSender<()>>,
88 result_txs: Vec<std::sync::mpsc::SyncSender<VmValue>>,
92 snapshot_binding: Option<harn_vm::value::DictMap>,
94}
95
96#[derive(Default)]
97struct HandleStore {
98 entries: BTreeMap<String, HandleEntry>,
99}
100
101static HANDLE_STORE: LazyLock<Mutex<HandleStore>> =
102 LazyLock::new(|| Mutex::new(HandleStore::default()));
103
104type HandleNotifiers = (
105 Option<std::sync::mpsc::SyncSender<()>>,
106 Vec<std::sync::mpsc::SyncSender<VmValue>>,
107);
108
109fn take_handle_notifiers(handle_id: &str) -> HandleNotifiers {
110 let mut store = HANDLE_STORE
111 .lock()
112 .expect("long-running handle store poisoned");
113 store
114 .entries
115 .remove(handle_id)
116 .map(|mut entry| {
117 (
118 entry.completion_tx.take(),
119 std::mem::take(&mut entry.result_txs),
120 )
121 })
122 .unwrap_or((None, Vec::new()))
123}
124
125pub struct LongRunningHandleInfo {
128 pub command_id: String,
130 pub handle_id: String,
132 pub started_at: String,
134 pub pid: u32,
136 pub process_group_id: Option<u32>,
138 pub command_display: String,
140 pub snapshot_binding: Option<harn_vm::value::DictMap>,
142}
143
144pub(crate) struct LongRunningSpawnOptions {
145 pub(crate) env_mode: EnvMode,
146 pub(crate) env_remove: Vec<String>,
147 pub(crate) capture: CaptureConfig,
148 pub(crate) session_id: String,
149 pub(crate) progress_interval: Option<Duration>,
150 pub(crate) progress_max_inline_bytes: usize,
151 pub(crate) snapshot_binding: Option<harn_vm::value::DictMap>,
152}
153
154struct WaiterContext {
155 command_id: String,
156 handle_id: String,
157 session_id: String,
158 started_at: String,
159 process_group_id: Option<u32>,
160 command_display: String,
161 progress_interval: Option<Duration>,
162 progress_max_inline_bytes: usize,
163 snapshot_binding: Option<harn_vm::value::DictMap>,
164}
165
166struct ProgressThreadContext {
167 command_id: String,
168 handle_id: String,
169 session_id: String,
170 started_at: String,
171 command_display: String,
172 process_group_id: Option<u32>,
173 output_path: PathBuf,
174 stdout_path: PathBuf,
175 stderr_path: PathBuf,
176 output_state: Arc<Mutex<OutputState>>,
177 cancel_state: Arc<CancelState>,
178 done: Arc<AtomicBool>,
179 started: std::time::Instant,
180 interval: Duration,
181 max_inline_bytes: usize,
182 snapshot_binding: Option<harn_vm::value::DictMap>,
183}
184
185impl LongRunningHandleInfo {
186 pub fn into_handle_response(self) -> VmValue {
188 let Self {
189 command_id,
190 handle_id,
191 started_at,
192 pid,
193 process_group_id,
194 command_display,
195 snapshot_binding,
196 } = self;
197 proc::running_response(
198 command_id,
199 handle_id,
200 pid,
201 process_group_id,
202 started_at,
203 command_display,
204 snapshot_binding.as_ref(),
205 )
206 }
207}
208
209pub fn spawn_long_running(
215 builtin: &'static str,
216 program: String,
217 args: Vec<String>,
218 cwd: Option<PathBuf>,
219 env: BTreeMap<String, String>,
220 session_id: String,
221) -> Result<LongRunningHandleInfo, HostlibError> {
222 spawn_long_running_with_options(
223 builtin,
224 program,
225 args,
226 cwd,
227 env,
228 LongRunningSpawnOptions {
229 env_mode: EnvMode::InheritClean,
230 env_remove: Vec::new(),
231 capture: CaptureConfig::default(),
232 session_id,
233 progress_interval: None,
234 progress_max_inline_bytes: CaptureConfig::default().max_inline_bytes,
235 snapshot_binding: None,
236 },
237 )
238}
239
240pub(crate) fn spawn_long_running_with_options(
241 builtin: &'static str,
242 program: String,
243 args: Vec<String>,
244 cwd: Option<PathBuf>,
245 env: BTreeMap<String, String>,
246 options: LongRunningSpawnOptions,
247) -> Result<LongRunningHandleInfo, HostlibError> {
248 let mut env = env;
249 proc::apply_toolchain_path(cwd.as_deref(), &mut env, options.env_mode);
250 let spec = SpawnSpec {
251 builtin,
252 program: program.clone(),
253 args: args.clone(),
254 cwd,
255 env,
256 env_remove: options.env_remove.clone(),
257 env_mode: options.env_mode,
258 use_stdin: false,
259 configure_process_group: true,
260 output_capture: process_handle::OutputCapture::Pipe,
261 };
262 let handle = process_handle::spawn_process(spec)
263 .map_err(|e| proc::process_error_to_hostlib(builtin, e))?;
264
265 let pid = handle.pid().unwrap_or(0);
266 let process_group_id = handle.process_group_id();
267 let killer = handle.killer();
268 let id = HANDLE_COUNTER.fetch_add(1, Ordering::SeqCst);
269 let handle_id = format!("hto-{:x}-{id}", std::process::id());
270 let command_id = proc::next_command_id();
271 let started_at = proc::now_rfc3339();
272 let _artifacts = proc::register_live_artifacts(&command_id, Some(&handle_id))?;
273
274 let mut all_argv = vec![program];
275 all_argv.extend(args.iter().cloned());
276 let command_display = all_argv.join(" ");
277
278 let cancel_state = Arc::new(CancelState {
279 cancelled: AtomicBool::new(false),
280 timed_out: AtomicBool::new(false),
281 process_cleanup: Mutex::new(None),
282 });
283
284 {
285 let mut store = HANDLE_STORE
286 .lock()
287 .expect("long-running handle store poisoned");
288 store.entries.insert(
289 handle_id.clone(),
290 HandleEntry {
291 handle: Some(handle),
292 killer,
293 session_id: options.session_id.clone(),
294 cancel_state: cancel_state.clone(),
295 completion_tx: None,
296 result_txs: Vec::new(),
297 snapshot_binding: options.snapshot_binding.clone(),
298 },
299 );
300 }
301
302 let waiter_context = WaiterContext {
303 command_id: command_id.clone(),
304 handle_id: handle_id.clone(),
305 session_id: options.session_id,
306 started_at: started_at.clone(),
307 process_group_id,
308 command_display: command_display.clone(),
309 progress_interval: options.progress_interval,
310 progress_max_inline_bytes: options.progress_max_inline_bytes,
311 snapshot_binding: options.snapshot_binding.clone(),
312 };
313 let waiter_thread_name = waiter_context.handle_id.clone();
314 let capture = options.capture;
315 std::thread::Builder::new()
316 .name(format!("hto-waiter-{waiter_thread_name}"))
317 .spawn(move || {
318 waiter_thread(waiter_context, cancel_state, capture);
319 })
320 .map_err(|e| HostlibError::Backend {
321 builtin,
322 message: format!("failed to spawn waiter thread: {e}"),
323 })?;
324
325 Ok(LongRunningHandleInfo {
326 command_id,
327 handle_id,
328 started_at,
329 pid,
330 process_group_id,
331 command_display,
332 snapshot_binding: options.snapshot_binding,
333 })
334}
335
336fn waiter_thread(context: WaiterContext, cancel_state: Arc<CancelState>, capture: CaptureConfig) {
338 let waiter_start = std::time::Instant::now();
339
340 let mut handle = {
343 let mut store = HANDLE_STORE
344 .lock()
345 .expect("long-running handle store poisoned");
346 match store.entries.get_mut(&context.handle_id) {
347 Some(entry) => match entry.handle.take() {
348 Some(h) => h,
349 None => return, },
351 None => return, }
353 };
354
355 let output_state = Arc::new(Mutex::new(OutputState::default()));
356 let done = Arc::new(AtomicBool::new(false));
357 let planned = proc::planned_artifact_paths(&context.command_id);
358 if let Some(parent) = planned.output_path.parent() {
359 let _ = std::fs::create_dir_all(parent);
360 }
361 let _ = std::fs::File::create(&planned.stdout_path);
362 let _ = std::fs::File::create(&planned.stderr_path);
363 let combined_file = std::fs::File::create(&planned.output_path)
364 .ok()
365 .map(|file| Arc::new(Mutex::new(file)));
366
367 let stdout_thread = handle.take_stdout().map(|out| {
368 spawn_output_drain(
369 out,
370 output_state.clone(),
371 planned.stdout_path.clone(),
372 combined_file.clone(),
373 true,
374 )
375 });
376 let stderr_thread = handle.take_stderr().map(|err| {
377 spawn_output_drain(
378 err,
379 output_state.clone(),
380 planned.stderr_path.clone(),
381 combined_file.clone(),
382 false,
383 )
384 });
385
386 let progress_thread = context
387 .progress_interval
388 .filter(|interval| !interval.is_zero())
389 .map(|interval| {
390 spawn_progress_thread(ProgressThreadContext {
391 command_id: context.command_id.clone(),
392 handle_id: context.handle_id.clone(),
393 session_id: context.session_id.clone(),
394 started_at: context.started_at.clone(),
395 command_display: context.command_display.clone(),
396 process_group_id: context.process_group_id,
397 output_path: planned.output_path.clone(),
398 stdout_path: planned.stdout_path.clone(),
399 stderr_path: planned.stderr_path.clone(),
400 output_state: output_state.clone(),
401 cancel_state: cancel_state.clone(),
402 done: done.clone(),
403 started: waiter_start,
404 interval,
405 max_inline_bytes: context.progress_max_inline_bytes,
406 snapshot_binding: context.snapshot_binding.clone(),
407 })
408 });
409
410 let status = handle.wait().ok();
411
412 if let Some(thread) = stdout_thread {
413 let _ = thread.join();
414 }
415 if let Some(thread) = stderr_thread {
416 let _ = thread.join();
417 }
418 done.store(true, Ordering::Release);
419 drop(progress_thread);
420 let (stdout, stderr) = {
421 let state = output_state
422 .lock()
423 .unwrap_or_else(|poison| poison.into_inner());
424 (state.stdout.clone(), state.stderr.clone())
425 };
426
427 let cancelled = cancel_state.cancelled.load(Ordering::Acquire);
428 let timed_out = cancelled && cancel_state.timed_out.load(Ordering::Acquire);
429 let process_cleanup = cancel_state
430 .process_cleanup
431 .lock()
432 .unwrap_or_else(|poison| poison.into_inner())
433 .clone();
434
435 let (exit_code, signal_name) = match status {
436 Some(s) => decode_exit_status(s),
437 None => (-1, Some("SIGKILL".to_string())),
439 };
440 let command_status = if timed_out {
441 CommandStatus::TimedOut
442 } else if cancelled {
443 CommandStatus::Killed
444 } else {
445 CommandStatus::Completed
446 };
447 let duration = waiter_start.elapsed();
448 let duration_ms = duration.as_millis() as i64;
449 let artifacts = match proc::persist_artifacts(
450 &context.command_id,
451 &stdout,
452 &stderr,
453 Some(&context.handle_id),
454 ) {
455 Ok(artifacts) => artifacts,
456 Err(_) => {
457 let (completion_tx, _result_txs) = take_handle_notifiers(&context.handle_id);
458 if let Some(tx) = completion_tx {
459 let _ = tx.try_send(());
460 }
461 return;
462 }
463 };
464 let (inline_stdout, inline_stderr) = proc::inline_output(&stdout, &stderr, capture);
465
466 let mut payload = serde_json::Map::new();
467 payload.insert(
468 "command_id".into(),
469 serde_json::Value::String(context.command_id.clone()),
470 );
471 payload.insert(
472 "status".into(),
473 serde_json::Value::String(command_status.as_str().to_string()),
474 );
475 payload.insert(
476 "handle_id".into(),
477 serde_json::Value::String(context.handle_id.clone()),
478 );
479 payload.insert(
480 "command_or_op_descriptor".into(),
481 serde_json::Value::String(context.command_display),
482 );
483 payload.insert(
484 "started_at".into(),
485 serde_json::Value::String(context.started_at),
486 );
487 payload.insert(
488 "ended_at".into(),
489 serde_json::Value::String(proc::now_rfc3339()),
490 );
491 payload.insert(
492 "duration_ms".into(),
493 serde_json::Value::Number(duration_ms.into()),
494 );
495 payload.insert(
496 "exit_code".into(),
497 serde_json::Value::Number(exit_code.into()),
498 );
499 payload.insert("timed_out".into(), serde_json::Value::Bool(timed_out));
500 payload.insert("stdout".into(), serde_json::Value::String(inline_stdout));
501 payload.insert("stderr".into(), serde_json::Value::String(inline_stderr));
502 payload.insert(
503 "output_path".into(),
504 serde_json::Value::String(to_agent_path(&artifacts.output_path)),
505 );
506 payload.insert(
507 "stdout_path".into(),
508 serde_json::Value::String(to_agent_path(&artifacts.stdout_path)),
509 );
510 payload.insert(
511 "stderr_path".into(),
512 serde_json::Value::String(to_agent_path(&artifacts.stderr_path)),
513 );
514 payload.insert(
515 "line_count".into(),
516 serde_json::Value::Number(artifacts.line_count.into()),
517 );
518 payload.insert(
519 "byte_count".into(),
520 serde_json::Value::Number(artifacts.byte_count.into()),
521 );
522 payload.insert(
523 "output_sha256".into(),
524 serde_json::Value::String(artifacts.output_sha256),
525 );
526 if let Some(pgid) = context.process_group_id {
527 payload.insert(
528 "process_group_id".into(),
529 serde_json::Value::Number((pgid as u64).into()),
530 );
531 }
532 if let Some(sig) = signal_name {
533 payload.insert("signal".into(), serde_json::Value::String(sig));
534 } else {
535 payload.insert("signal".into(), serde_json::Value::Null);
536 }
537 if let Some(snapshot_binding) = context.snapshot_binding.as_ref() {
538 payload.insert("snapshot_binding".into(), vm_dict_to_json(snapshot_binding));
539 }
540 if let Some(process_cleanup) = process_cleanup.as_ref() {
541 payload.insert(
542 "process_cleanup".into(),
543 proc::process_cleanup_to_json(process_cleanup),
544 );
545 }
546
547 let result_value = harn_vm::json_to_vm_value(&serde_json::Value::Object(payload.clone()));
548 if !cancelled {
549 let content = serde_json::to_string(&payload).unwrap_or_default();
550 harn_vm::orchestration::agent_inbox::push(
551 &context.session_id,
552 "tool_result",
553 &content,
554 "hostlib.long_running.exit",
555 );
556 }
557 let (completion_tx, result_txs) = take_handle_notifiers(&context.handle_id);
563 for tx in result_txs {
564 let _ = tx.try_send(result_value.clone());
565 }
566 if let Some(tx) = completion_tx {
567 let _ = tx.try_send(());
568 }
569}
570
571fn spawn_output_drain(
572 mut reader: Box<dyn Read + Send>,
573 state: Arc<Mutex<OutputState>>,
574 path: std::path::PathBuf,
575 combined_file: Option<Arc<Mutex<std::fs::File>>>,
576 stdout: bool,
577) -> std::thread::JoinHandle<()> {
578 std::thread::spawn(move || {
579 let mut file = std::fs::File::create(path).ok();
580 let mut buf = [0_u8; 8192];
581 loop {
582 let read = match reader.read(&mut buf) {
583 Ok(0) => break,
584 Ok(read) => read,
585 Err(_) => break,
586 };
587 let chunk = &buf[..read];
588 if let Some(file) = file.as_mut() {
589 let _ = file.write_all(chunk);
590 }
591 if let Some(combined) = combined_file.as_ref() {
592 if let Ok(mut combined) = combined.lock() {
593 let _ = combined.write_all(chunk);
594 }
595 }
596 if let Ok(mut state) = state.lock() {
597 if stdout {
598 state.stdout.extend_from_slice(chunk);
599 } else {
600 state.stderr.extend_from_slice(chunk);
601 }
602 }
603 }
604 })
605}
606
607fn spawn_progress_thread(context: ProgressThreadContext) -> std::thread::JoinHandle<()> {
608 std::thread::spawn(move || {
609 while !context.done.load(Ordering::Acquire)
610 && !context.cancel_state.cancelled.load(Ordering::Acquire)
611 {
612 std::thread::sleep(context.interval);
613 if context.done.load(Ordering::Acquire)
614 || context.cancel_state.cancelled.load(Ordering::Acquire)
615 {
616 break;
617 }
618 let (stdout, stderr) = {
619 let state = context
620 .output_state
621 .lock()
622 .unwrap_or_else(|poison| poison.into_inner());
623 (state.stdout.clone(), state.stderr.clone())
624 };
625 let capture = CaptureConfig {
626 max_inline_bytes: context.max_inline_bytes,
627 ..CaptureConfig::default()
628 };
629 let (inline_stdout, inline_stderr) = proc::inline_output(&stdout, &stderr, capture);
630 let byte_count = stdout.len().saturating_add(stderr.len());
631 let mut payload = serde_json::json!({
632 "command_id": &context.command_id,
633 "handle_id": &context.handle_id,
634 "status": CommandStatus::Running.as_str(),
635 "command_or_op_descriptor": &context.command_display,
636 "started_at": &context.started_at,
637 "ended_at": null,
638 "duration_ms": context.started.elapsed().as_millis() as i64,
639 "exit_code": null,
640 "signal": null,
641 "stdout": inline_stdout,
642 "stderr": inline_stderr,
643 "output_path": to_agent_path(&context.output_path),
644 "stdout_path": to_agent_path(&context.stdout_path),
645 "stderr_path": to_agent_path(&context.stderr_path),
646 "byte_count": byte_count as i64,
647 "line_count": stdout.iter().chain(stderr.iter()).filter(|byte| **byte == b'\n').count() as i64,
648 "process_group_id": context.process_group_id,
649 });
650 if let (Some(object), Some(snapshot_binding)) =
651 (payload.as_object_mut(), context.snapshot_binding.as_ref())
652 {
653 object.insert(
654 "snapshot_binding".to_string(),
655 vm_dict_to_json(snapshot_binding),
656 );
657 }
658 harn_vm::orchestration::agent_inbox::push(
659 &context.session_id,
660 "tool_progress",
661 &payload.to_string(),
662 "hostlib.long_running.progress",
663 );
664 }
665 })
666}
667
668pub(crate) struct CancelOptions {
669 pub(crate) timed_out: bool,
670 pub(crate) wait_result: Option<Duration>,
671}
672
673pub(crate) struct CancelOutcome {
674 pub(crate) cancelled: bool,
675 pub(crate) result: Option<VmValue>,
676}
677
678pub fn cancel_handle(handle_id: &str) -> bool {
682 cancel_handle_with_options(
683 handle_id,
684 CancelOptions {
685 timed_out: false,
686 wait_result: None,
687 },
688 )
689 .cancelled
690}
691
692pub(crate) fn snapshot_binding_for_handle(handle_id: &str) -> Option<harn_vm::value::DictMap> {
693 let store = HANDLE_STORE
694 .lock()
695 .expect("long-running handle store poisoned");
696 store
697 .entries
698 .get(handle_id)
699 .and_then(|entry| entry.snapshot_binding.clone())
700}
701
702pub(crate) fn cancel_handle_with_options(handle_id: &str, options: CancelOptions) -> CancelOutcome {
703 let (killer, cancel_state, result_rx) = {
704 let mut store = HANDLE_STORE
705 .lock()
706 .expect("long-running handle store poisoned");
707 let Some(entry) = store.entries.get_mut(handle_id) else {
708 return CancelOutcome {
709 cancelled: false,
710 result: None,
711 };
712 };
713 if entry.cancel_state.cancelled.swap(true, Ordering::AcqRel) {
714 return CancelOutcome {
715 cancelled: false,
716 result: None,
717 };
718 }
719 entry
720 .cancel_state
721 .timed_out
722 .store(options.timed_out, Ordering::Release);
723 let result_rx = options.wait_result.map(|_| {
724 let (tx, rx) = std::sync::mpsc::sync_channel::<VmValue>(1);
725 entry.result_txs.push(tx);
726 rx
727 });
728 (entry.killer.clone(), entry.cancel_state.clone(), result_rx)
729 };
730 do_kill(killer, cancel_state);
731 let result = match (options.wait_result, result_rx) {
732 (Some(timeout), Some(rx)) => rx.recv_timeout(timeout).ok(),
733 _ => None,
734 };
735 CancelOutcome {
736 cancelled: true,
737 result,
738 }
739}
740
741pub(crate) fn wait_for_result(handle_id: &str, timeout: Duration) -> Option<VmValue> {
748 if timeout.is_zero() {
749 return None;
750 }
751 let rx = {
752 let mut store = HANDLE_STORE
753 .lock()
754 .expect("long-running handle store poisoned");
755 let entry = store.entries.get_mut(handle_id)?;
756 let (tx, rx) = std::sync::mpsc::sync_channel::<VmValue>(1);
757 entry.result_txs.push(tx);
758 rx
759 };
760 rx.recv_timeout(timeout).ok()
761}
762
763type SessionKillEntry = (Arc<dyn ProcessKiller>, Arc<CancelState>);
767
768pub fn cancel_session_handles(session_id: &str) {
771 let to_kill: Vec<SessionKillEntry> = {
772 let store = HANDLE_STORE
773 .lock()
774 .expect("long-running handle store poisoned");
775 let matching: Vec<String> = store
776 .entries
777 .iter()
778 .filter(|(_, e)| e.session_id == session_id)
779 .map(|(id, _)| id.clone())
780 .collect();
781 matching
782 .into_iter()
783 .filter_map(|id| {
784 let entry = store.entries.get(&id)?;
785 if entry.cancel_state.cancelled.swap(true, Ordering::AcqRel) {
786 return None;
787 }
788 entry.cancel_state.timed_out.store(false, Ordering::Release);
789 Some((entry.killer.clone(), entry.cancel_state.clone()))
790 })
791 .collect()
792 };
793 for (killer, cancel_state) in to_kill {
794 do_kill(killer, cancel_state);
795 }
796}
797
798fn do_kill(killer: Arc<dyn ProcessKiller>, cancel_state: Arc<CancelState>) {
801 let report = killer.kill();
804 {
805 let mut stored = cancel_state
806 .process_cleanup
807 .lock()
808 .unwrap_or_else(|poison| poison.into_inner());
809 match stored.as_mut() {
810 Some(existing) => existing.merge(report),
811 None => *stored = Some(report),
812 }
813 }
814 cancel_state.cancelled.store(true, Ordering::Release);
815}
816
817pub(crate) fn register_cleanup_hook() {
821 static REGISTERED: OnceLock<()> = OnceLock::new();
822 REGISTERED.get_or_init(|| {
823 let hook: Arc<dyn Fn(&str) + Send + Sync> = Arc::new(|session_id: &str| {
824 cancel_session_handles(session_id);
825 });
826 harn_vm::register_session_end_hook(hook);
827 });
828}
829
830fn decode_exit_status(status: process_handle::ExitStatus) -> (i32, Option<String>) {
831 if let Some(code) = status.code {
832 return (code, None);
833 }
834 if let Some(sig) = status.signal {
835 return (-1, Some(format!("SIG{sig}")));
836 }
837 (-1, None)
838}
839
840pub fn register_completion_notifier(handle_id: &str) -> Option<std::sync::mpsc::Receiver<()>> {
846 let (tx, rx) = std::sync::mpsc::sync_channel::<()>(1);
847 let mut store = HANDLE_STORE
848 .lock()
849 .expect("long-running handle store poisoned");
850 let entry = store.entries.get_mut(handle_id)?;
851 entry.completion_tx = Some(tx);
852 Some(rx)
853}
854
855pub fn register_result_notifier(handle_id: &str) -> Option<std::sync::mpsc::Receiver<VmValue>> {
861 let (tx, rx) = std::sync::mpsc::sync_channel::<VmValue>(1);
862 let mut store = HANDLE_STORE
863 .lock()
864 .expect("long-running handle store poisoned");
865 let entry = store.entries.get_mut(handle_id)?;
866 entry.result_txs.push(tx);
867 Some(rx)
868}