cpex_core/executor.rs
1// Location: ./crates/cpex-core/src/executor.rs
2// Copyright 2025
3// SPDX-License-Identifier: Apache-2.0
4// Authors: Teryl Taylor
5//
6// 5-phase plugin execution engine.
7//
8// Dispatches plugins in strict phase order:
9// SEQUENTIAL → TRANSFORM → AUDIT → CONCURRENT → FIRE_AND_FORGET
10//
11// Each phase has different authority (block/modify) and scheduling
12// (serial/parallel/background). The executor reads all scheduling
13// decisions from PluginRef.trusted_config — never from the plugin.
14//
15// Extensions are passed separately from the payload and capability-
16// filtered per plugin before dispatch. Extension modifications are
17// merged back independently from payload modifications.
18//
19// Error handling respects the plugin's on_error setting:
20// - Fail: propagate error, halt pipeline
21// - Ignore: log error, continue pipeline
22// - Disable: log error, mark plugin disabled, continue
23//
24// Mirrors the Python framework's PluginExecutor in
25// cpex/framework/manager.py.
26
27use std::any::Any;
28use std::fmt;
29use std::sync::Arc;
30use std::time::Duration;
31
32use tokio::time::timeout;
33use tracing::{error, warn};
34
35use crate::context::PluginContextTable;
36use crate::error::PluginError;
37use crate::extensions::filter_extensions;
38use crate::hooks::payload::{Extensions, PluginPayload, WriteToken};
39use crate::plugin::OnError;
40use crate::registry::{group_by_mode, HookEntry};
41
42// ---------------------------------------------------------------------------
43// Executor Configuration
44// ---------------------------------------------------------------------------
45
46/// Configuration for the executor.
47#[derive(Debug, Clone)]
48pub struct ExecutorConfig {
49 /// Maximum execution time per plugin in seconds.
50 pub timeout_seconds: u64,
51
52 /// Whether to halt on the first deny in concurrent mode.
53 pub short_circuit_on_deny: bool,
54}
55
56impl Default for ExecutorConfig {
57 fn default() -> Self {
58 Self {
59 timeout_seconds: 30,
60 short_circuit_on_deny: true,
61 }
62 }
63}
64
65// ---------------------------------------------------------------------------
66// Pipeline Result
67// ---------------------------------------------------------------------------
68
69/// Aggregate result from a full hook invocation across all phases.
70///
71/// Wraps the final payload, extensions, any violation, and the
72/// context table. Immutable by design — policy decisions cannot be
73/// tampered with after the executor returns them.
74///
75/// The caller should pass `context_table` into the next hook
76/// invocation to preserve per-plugin local state across hooks in
77/// the same request lifecycle.
78///
79/// Background tasks are returned separately as [`BackgroundTasks`]
80/// to keep the policy result immutable.
81#[derive(Debug)]
82pub struct PipelineResult {
83 /// Whether the pipeline should continue processing.
84 /// `false` means a plugin denied — the pipeline was halted.
85 pub continue_processing: bool,
86
87 /// The final payload after all modifications (type-erased).
88 /// `None` if the pipeline was denied before any modifications.
89 pub modified_payload: Option<Box<dyn PluginPayload>>,
90
91 /// The final extensions after all modifications.
92 /// `None` if no plugin modified extensions.
93 pub modified_extensions: Option<Extensions>,
94
95 /// The violation that caused a deny, if any.
96 pub violation: Option<crate::error::PluginViolation>,
97
98 /// Errors from plugins that ran with `on_error: ignore` or
99 /// `on_error: disable`. These plugins didn't halt the pipeline
100 /// (their on_error policy said to continue), but the caller
101 /// should still know the errors happened so it can log them in
102 /// a structured way, retry the affected plugin, or alert.
103 /// Empty when no plugin errored on a non-halt path.
104 /// Fire-and-forget errors live in `BackgroundTasks` instead.
105 pub errors: Vec<crate::error::PluginErrorRecord>,
106
107 /// Optional metadata aggregated from plugins (telemetry, diagnostics).
108 pub metadata: Option<serde_json::Value>,
109
110 /// Plugin contexts indexed by plugin ID. Thread this into the
111 /// next hook invocation to preserve per-plugin `local_state`.
112 pub context_table: PluginContextTable,
113}
114
115impl PipelineResult {
116 /// Pipeline completed — all plugins allowed.
117 pub fn allowed_with(
118 payload: Box<dyn PluginPayload>,
119 extensions: Extensions,
120 context_table: PluginContextTable,
121 ) -> Self {
122 Self {
123 continue_processing: true,
124 modified_payload: Some(payload),
125 modified_extensions: Some(extensions),
126 violation: None,
127 errors: Vec::new(),
128 metadata: None,
129 context_table,
130 }
131 }
132
133 /// Pipeline was denied by a plugin.
134 pub fn denied(
135 violation: crate::error::PluginViolation,
136 extensions: Extensions,
137 context_table: PluginContextTable,
138 ) -> Self {
139 Self {
140 continue_processing: false,
141 modified_payload: None,
142 modified_extensions: Some(extensions),
143 violation: Some(violation),
144 errors: Vec::new(),
145 metadata: None,
146 context_table,
147 }
148 }
149
150 /// Replace the errors vec on a constructed PipelineResult. Used by
151 /// the executor to attach errors collected from `on_error: ignore`
152 /// / `on_error: disable` plugins.
153 pub fn with_errors(mut self, errors: Vec<crate::error::PluginErrorRecord>) -> Self {
154 self.errors = errors;
155 self
156 }
157
158 /// Whether this result represents a denial.
159 pub fn is_denied(&self) -> bool {
160 !self.continue_processing
161 }
162}
163
164// ---------------------------------------------------------------------------
165// Background Tasks
166// ---------------------------------------------------------------------------
167
168/// Handles to fire-and-forget background tasks spawned by the executor.
169///
170/// Returned separately from [`PipelineResult`] so that the policy
171/// result stays immutable. If not awaited, tasks complete on their
172/// own in the background. Call `wait_for_background_tasks()` when you
173/// need to ensure tasks have finished (tests, graceful shutdown,
174/// audit flush).
175pub struct BackgroundTasks {
176 tasks: Vec<(String, tokio::task::JoinHandle<()>)>,
177}
178
179impl BackgroundTasks {
180 /// Create an empty set of background tasks.
181 pub fn empty() -> Self {
182 Self { tasks: Vec::new() }
183 }
184
185 /// Create from a list of (plugin_name, handle) pairs.
186 fn from_handles(tasks: Vec<(String, tokio::task::JoinHandle<()>)>) -> Self {
187 Self { tasks }
188 }
189
190 /// Whether there are any background tasks.
191 pub fn is_empty(&self) -> bool {
192 self.tasks.is_empty()
193 }
194
195 /// Number of background tasks.
196 pub fn len(&self) -> usize {
197 self.tasks.len()
198 }
199
200 /// Wait for all fire-and-forget background tasks to complete.
201 ///
202 /// Returns a list of errors from any tasks that panicked.
203 /// An empty list means all tasks completed successfully.
204 ///
205 /// Consumes `self` — each task handle can only be awaited once.
206 ///
207 /// If not called, background tasks still complete on their own.
208 /// Use this for tests, graceful shutdown, or when you need to
209 /// ensure audit/logging tasks have flushed before proceeding.
210 pub async fn wait_for_background_tasks(self) -> Vec<crate::error::PluginError> {
211 let mut errors = Vec::new();
212 for (plugin_name, handle) in self.tasks {
213 if let Err(e) = handle.await {
214 errors.push(crate::error::PluginError::Execution {
215 plugin_name,
216 message: format!("background task panicked: {}", e),
217 source: None,
218 code: None,
219 details: std::collections::HashMap::new(),
220 proto_error_code: None,
221 });
222 }
223 }
224 errors
225 }
226}
227
228impl fmt::Debug for BackgroundTasks {
229 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
230 f.debug_struct("BackgroundTasks")
231 .field("count", &self.tasks.len())
232 .finish()
233 }
234}
235
236// ---------------------------------------------------------------------------
237// Executor
238// ---------------------------------------------------------------------------
239
240/// 5-phase plugin execution engine.
241///
242/// Dispatches hooks through the phase pipeline:
243///
244/// ```text
245/// SEQUENTIAL → TRANSFORM → AUDIT → CONCURRENT → FIRE_AND_FORGET
246/// ```
247///
248/// The executor is stateless — all state comes from the arguments.
249/// One executor instance can serve multiple concurrent hook invocations.
250#[derive(Clone)]
251pub struct Executor {
252 config: ExecutorConfig,
253}
254
255impl Executor {
256 /// Create a new executor with the given configuration.
257 pub fn new(config: ExecutorConfig) -> Self {
258 Self { config }
259 }
260
261 /// Execute a hook invocation through the 5-phase pipeline.
262 ///
263 /// # Arguments
264 ///
265 /// * `entries` — HookEntries for this hook, sorted by priority.
266 /// * `payload` — The typed payload (type-erased as Box<dyn PluginPayload>).
267 /// * `extensions` — The full extensions (filtered per plugin before dispatch).
268 /// * `context_table` — Optional context table from a previous hook invocation.
269 /// If `None`, fresh contexts are created for each plugin.
270 ///
271 /// # Returns
272 ///
273 /// A tuple of:
274 /// - `PipelineResult` — immutable policy result with payload,
275 /// extensions, violation, and context table.
276 /// - `BackgroundTasks` — handles to fire-and-forget tasks. Call
277 /// `wait_for_background_tasks()` to await them, or drop to let
278 /// them complete in the background.
279 pub async fn execute(
280 &self,
281 entries: &[HookEntry],
282 payload: Box<dyn PluginPayload>,
283 extensions: Extensions,
284 context_table: Option<PluginContextTable>,
285 task_tracker: &tokio_util::task::TaskTracker,
286 ) -> (PipelineResult, BackgroundTasks) {
287 let mut ctx_table = context_table.unwrap_or_default();
288
289 if entries.is_empty() {
290 return (
291 PipelineResult::allowed_with(payload, extensions, ctx_table),
292 BackgroundTasks::empty(),
293 );
294 }
295
296 // Group entries by mode (from trusted_config)
297 let (sequential, transform, audit, concurrent, fire_and_forget) = group_by_mode(entries);
298
299 let mut current_payload = payload;
300 let mut current_extensions = extensions;
301 // Accumulator for errors from `on_error: ignore` / `on_error:
302 // disable` plugins across all phases. Surfaced to the caller
303 // via `PipelineResult.errors` so swallowed failures stay
304 // observable. Halt-condition errors (Fail, deny) skip this and
305 // become the violation directly.
306 let mut errors: Vec<crate::error::PluginErrorRecord> = Vec::new();
307
308 // Phase 1: SEQUENTIAL — serial, chained, can block + modify
309 if let Some(v) = self
310 .run_serial_phase(
311 &sequential,
312 &mut current_payload,
313 &mut current_extensions,
314 &mut ctx_table,
315 true, // can_block
316 true, // can_modify
317 "SEQUENTIAL",
318 &mut errors,
319 )
320 .await
321 {
322 return (
323 PipelineResult::denied(v, current_extensions, ctx_table).with_errors(errors),
324 BackgroundTasks::empty(),
325 );
326 }
327
328 // Phase 2: TRANSFORM — serial, chained, can modify, cannot block
329 // can_block=false means denials are suppressed (returns None)
330 self.run_serial_phase(
331 &transform,
332 &mut current_payload,
333 &mut current_extensions,
334 &mut ctx_table,
335 false, // can_block
336 true, // can_modify
337 "TRANSFORM",
338 &mut errors,
339 )
340 .await;
341
342 // Phase 3: AUDIT — serial, read-only, discard results
343 self.run_ref_phase(
344 &audit,
345 &*current_payload,
346 ¤t_extensions,
347 &ctx_table,
348 "AUDIT",
349 &mut errors,
350 )
351 .await;
352
353 // Phase 4: CONCURRENT — parallel, can block, cannot modify
354 if let Some(violation) = self
355 .run_concurrent_phase(
356 &concurrent,
357 &*current_payload,
358 ¤t_extensions,
359 &ctx_table,
360 &mut errors,
361 )
362 .await
363 {
364 return (
365 PipelineResult::denied(violation, current_extensions, ctx_table)
366 .with_errors(errors),
367 BackgroundTasks::empty(),
368 );
369 }
370
371 // Phase 5: FIRE_AND_FORGET — background, read-only, ignore results.
372 // FAF errors don't go in PipelineResult.errors — they're delivered
373 // via BackgroundTasks::wait_for_background_tasks() instead.
374 let bg_handles = self.spawn_fire_and_forget(
375 &fire_and_forget,
376 &*current_payload,
377 ¤t_extensions,
378 &ctx_table,
379 task_tracker,
380 );
381
382 (
383 PipelineResult::allowed_with(current_payload, current_extensions, ctx_table)
384 .with_errors(errors),
385 BackgroundTasks::from_handles(bg_handles),
386 )
387 }
388
389 // -----------------------------------------------------------------------
390 // Phase 1 & 2: Serial execution (SEQUENTIAL / TRANSFORM)
391 // -----------------------------------------------------------------------
392
393 /// Run a serial phase — plugins execute one at a time, each seeing
394 /// the (possibly modified) payload from the previous.
395 ///
396 /// The framework retains ownership of the payload. Handlers receive
397 /// a borrow and clone only if they modify. Modified payloads in
398 /// the result replace the current payload.
399 ///
400 /// Each plugin's context is looked up in the context table (preserving
401 /// `local_state` from previous hooks) or created fresh. After execution,
402 /// `global_state` changes are merged back so the next plugin sees them.
403 #[allow(clippy::too_many_arguments)] // internal phase helper — args have distinct types and meaning
404 async fn run_serial_phase(
405 &self,
406 entries: &[HookEntry],
407 payload: &mut Box<dyn PluginPayload>,
408 extensions: &mut Extensions,
409 ctx_table: &mut PluginContextTable,
410 can_block: bool,
411 can_modify: bool,
412 phase_label: &str,
413 errors: &mut Vec<crate::error::PluginErrorRecord>,
414 ) -> Option<crate::error::PluginViolation> {
415 for entry in entries {
416 // Borrow names/ids on the happy path — allocate only when
417 // building a violation or stashing the local_state back into
418 // the table. Previously `name.to_string()` + `id.to_string()`
419 // ran unconditionally on every plugin per invoke.
420 let plugin_name = entry.plugin_ref.name();
421 let plugin_id = entry.plugin_ref.id();
422 let on_error = entry.plugin_ref.trusted_config().on_error;
423
424 // Take this plugin's context out of the table — pulls its stored
425 // local_state and seeds global_state from the canonical store.
426 // Replaces the previous values().last() seed, which was
427 // non-deterministic across HashMap iteration orders.
428 let mut ctx = ctx_table.take_context(plugin_id);
429
430 // Filter extensions per plugin based on declared capabilities.
431 // Produces a filtered view with None for ungated slots.
432 // Also sets write tokens for plugins with write capabilities.
433 let capabilities: std::collections::HashSet<String> = entry
434 .plugin_ref
435 .trusted_config()
436 .capabilities
437 .iter()
438 .cloned()
439 .collect();
440 let mut filtered = filter_extensions(extensions, &capabilities);
441
442 // Set write tokens based on capabilities
443 if capabilities.contains("write_headers") {
444 filtered.http_write_token = Some(WriteToken::new());
445 }
446 if capabilities.contains("append_labels") {
447 filtered.labels_write_token = Some(WriteToken::new());
448 }
449 if capabilities.contains("append_delegation") {
450 filtered.delegation_write_token = Some(WriteToken::new());
451 }
452
453 // Execute with timeout — handler borrows payload, gets filtered extensions
454 let timeout_dur = Duration::from_secs(self.config.timeout_seconds);
455 let result = timeout(
456 timeout_dur,
457 entry.handler.invoke(&**payload, &filtered, &mut ctx),
458 )
459 .await;
460
461 match result {
462 Ok(Ok(result_box)) => {
463 if let Some(erased) = extract_erased(result_box) {
464 // Check deny
465 if !erased.continue_processing && can_block {
466 if let Some(mut v) = erased.violation {
467 v.plugin_name = Some(plugin_name.to_string());
468 return Some(v);
469 }
470 }
471
472 // Accept modifications
473 if can_modify {
474 if let Some(mp) = erased.modified_payload {
475 *payload = mp;
476 }
477 if let Some(owned) = erased.modified_extensions {
478 // Validate tier constraints before accepting
479 let valid = extensions.validate_immutable(&owned);
480 if !valid {
481 warn!(
482 "{} plugin '{}' violated immutable tier — \
483 modified an immutable extension slot. \
484 Extension changes rejected.",
485 phase_label, plugin_name
486 );
487 } else if capabilities.contains("read_labels") {
488 // Only enforce monotonic labels if the plugin
489 // could see them. A plugin without read_labels
490 // has empty labels in its filtered view — that's
491 // not a removal.
492 if let (Some(ref orig_sec), Some(ref new_sec)) =
493 (&extensions.security, &owned.security)
494 {
495 if !new_sec.labels.is_superset(&orig_sec.labels) {
496 warn!(
497 "{} plugin '{}' violated monotonic tier — \
498 removed a security label. \
499 Extension changes rejected.",
500 phase_label, plugin_name
501 );
502 } else {
503 extensions.merge_owned(owned);
504 }
505 } else {
506 extensions.merge_owned(owned);
507 }
508 } else {
509 extensions.merge_owned(owned);
510 }
511 }
512 }
513
514 // Plugin writes to ctx.global_state are committed back
515 // to the canonical store via store_context() below.
516 }
517 // If extract failed or no modifications — payload unchanged
518 },
519 Ok(Err(e)) => {
520 error!("{} plugin '{}' failed: {}", phase_label, plugin_name, e);
521 match on_error {
522 OnError::Fail if can_block => {
523 let mut v = crate::error::PluginViolation::new(
524 "plugin_error",
525 format!("Plugin '{}' failed: {}", plugin_name, e),
526 );
527 v.plugin_name = Some(plugin_name.to_string());
528 return Some(v);
529 },
530 // Any non-halt outcome (Fail-in-non-blocking-phase,
531 // Ignore, Disable): record the error so the caller
532 // sees it in PipelineResult.errors instead of
533 // having to read the warn-log.
534 OnError::Fail => {
535 warn!(
536 "{} plugin '{}' on_error=fail in non-blocking phase — not halting",
537 phase_label, plugin_name,
538 );
539 errors.push((&e).into());
540 },
541 OnError::Ignore => {
542 errors.push((&e).into());
543 },
544 OnError::Disable => {
545 warn!(
546 "{} plugin '{}' disabled after error",
547 phase_label, plugin_name
548 );
549 errors.push((&e).into());
550 entry.plugin_ref.disable();
551 },
552 }
553 },
554 Err(_) => {
555 error!("{} plugin '{}' timed out", phase_label, plugin_name);
556 let timeout_err = crate::error::PluginError::Timeout {
557 plugin_name: plugin_name.to_string(),
558 timeout_ms: timeout_dur.as_millis() as u64,
559 proto_error_code: None,
560 };
561 match on_error {
562 OnError::Fail if can_block => {
563 let mut v = crate::error::PluginViolation::new(
564 "plugin_timeout",
565 format!("Plugin '{}' timed out", plugin_name),
566 );
567 v.plugin_name = Some(plugin_name.to_string());
568 return Some(v);
569 },
570 OnError::Fail => {
571 warn!(
572 "{} plugin '{}' on_error=fail (timeout) in non-blocking phase — not halting",
573 phase_label, plugin_name,
574 );
575 errors.push((&timeout_err).into());
576 },
577 OnError::Ignore => {
578 errors.push((&timeout_err).into());
579 },
580 OnError::Disable => {
581 warn!(
582 "{} plugin '{}' disabled after timeout",
583 phase_label, plugin_name
584 );
585 errors.push((&timeout_err).into());
586 entry.plugin_ref.disable();
587 },
588 }
589 },
590 }
591
592 // Commit this plugin's context back to the table — replaces the
593 // canonical global_state with its (possibly modified) copy and
594 // stores the local_state for the next hook invocation. The
595 // global_state move is free; only the local_state insert allocates.
596 ctx_table.store_context(plugin_id, ctx);
597 }
598
599 None // no denial
600 }
601
602 // -----------------------------------------------------------------------
603 // Phase 3 & 5: Read-only execution (AUDIT / FIRE_AND_FORGET)
604 // -----------------------------------------------------------------------
605
606 /// Run a read-only phase — plugins receive &payload, results discarded.
607 async fn run_ref_phase(
608 &self,
609 entries: &[HookEntry],
610 payload: &dyn PluginPayload,
611 extensions: &Extensions,
612 ctx_table: &PluginContextTable,
613 phase_label: &str,
614 errors: &mut Vec<crate::error::PluginErrorRecord>,
615 ) {
616 for entry in entries {
617 let plugin_name = entry.plugin_ref.name().to_string();
618 let plugin_id = entry.plugin_ref.id();
619 let on_error = entry.plugin_ref.trusted_config().on_error;
620 // Read-only phase — snapshot the plugin's local_state and the
621 // canonical global_state, no merge-back.
622 let mut ctx = ctx_table.snapshot_context(plugin_id);
623 // Filter extensions per plugin — read-only, no write tokens.
624 let capabilities: std::collections::HashSet<String> = entry
625 .plugin_ref
626 .trusted_config()
627 .capabilities
628 .iter()
629 .cloned()
630 .collect();
631 let filtered = filter_extensions(extensions, &capabilities);
632 let timeout_dur = Duration::from_secs(self.config.timeout_seconds);
633
634 let result = timeout(
635 timeout_dur,
636 entry.handler.invoke(payload, &filtered, &mut ctx),
637 )
638 .await;
639
640 // Audit / fire-and-forget cannot block, so OnError::Fail can't
641 // halt the pipeline — but OnError::Disable must still take a
642 // repeatedly-failing plugin out of rotation. The previous code
643 // ignored on_error entirely, so Disable plugins kept failing
644 // forever no matter how many invocations errored. All non-halt
645 // failures also push a record into PipelineResult.errors.
646 match result {
647 Ok(Ok(_)) => {}, // read-only — discard result and ext_clone
648 Ok(Err(e)) => {
649 warn!(
650 "{} plugin '{}' error (ignored): {}",
651 phase_label, plugin_name, e
652 );
653 errors.push((&e).into());
654 if matches!(on_error, OnError::Disable) {
655 warn!(
656 "{} plugin '{}' disabled after error",
657 phase_label, plugin_name
658 );
659 entry.plugin_ref.disable();
660 }
661 },
662 Err(_) => {
663 warn!(
664 "{} plugin '{}' timed out (ignored)",
665 phase_label, plugin_name
666 );
667 let timeout_err = crate::error::PluginError::Timeout {
668 plugin_name: plugin_name.clone(),
669 timeout_ms: timeout_dur.as_millis() as u64,
670 proto_error_code: None,
671 };
672 errors.push((&timeout_err).into());
673 if matches!(on_error, OnError::Disable) {
674 warn!(
675 "{} plugin '{}' disabled after timeout",
676 phase_label, plugin_name
677 );
678 entry.plugin_ref.disable();
679 }
680 },
681 }
682 }
683 }
684
685 // -----------------------------------------------------------------------
686 // Phase 4: Concurrent (parallel, fail-fast)
687 // -----------------------------------------------------------------------
688
689 /// Run the concurrent phase — plugins execute truly in parallel.
690 /// Returns the first violation if any plugin denies.
691 ///
692 /// Built on `cpex_orchestration::run_branches`, the workspace's
693 /// shared "N async branches with abort-on-deny + per-branch timeout"
694 /// primitive (same crate apl-core's `Effect::Parallel` consumes).
695 /// Each branch returns a small `BranchData` carrying the plugin's
696 /// effective outcome (allow / deny / error). The orchestrator's
697 /// `is_deny` predicate inspects that — including the per-plugin
698 /// `on_error == Fail` case, which is treated as a halting outcome
699 /// so that an erroring/timing-out/panicking Fail-mode plugin
700 /// short-circuits the remaining branches the same way an explicit
701 /// deny does. Post-loop, we walk the outcomes in input order and
702 /// apply each plugin's `on_error` policy (Ignore / Disable) to
703 /// non-halting failures.
704 async fn run_concurrent_phase(
705 &self,
706 entries: &[HookEntry],
707 payload: &dyn PluginPayload,
708 extensions: &Extensions,
709 ctx_table: &PluginContextTable,
710 errors: &mut Vec<crate::error::PluginErrorRecord>,
711 ) -> Option<crate::error::PluginViolation> {
712 use cpex_orchestration::{run_branches, BranchConfig, BranchOutcome, ErasedBranch};
713
714 if entries.is_empty() {
715 return None;
716 }
717
718 // Per-branch outcome. Carries just enough for post-loop policy
719 // application — plugin name / on_error are looked up via
720 // `entries[idx]` so we don't have to clone them into the
721 // future's captures.
722 enum BranchData {
723 Allow,
724 Deny(Option<crate::error::PluginViolation>),
725 Error(Box<PluginError>),
726 }
727
728 // Clone the payload once so each spawned task can borrow from
729 // an owned, 'static copy. Each task gets its own Arc'd clone.
730 let shared_payload: Arc<Box<dyn PluginPayload>> = Arc::new(payload.clone_boxed());
731 let timeout_dur = Duration::from_secs(self.config.timeout_seconds);
732
733 // Snapshot per-entry on_error decisions BEFORE moving into
734 // futures — `is_deny` needs them at runtime to decide whether
735 // an Error outcome halts (Fail) or is logged (Ignore/Disable).
736 let on_error_by_idx: Vec<OnError> = entries
737 .iter()
738 .map(|e| e.plugin_ref.trusted_config().on_error)
739 .collect();
740
741 // Build branch futures. Each does the timing-bounded handler
742 // invoke and extracts the type-erased result, returning a
743 // `BranchData` that the orchestrator's `is_deny` predicate can
744 // inspect without further type knowledge.
745 let mut branches: Vec<ErasedBranch<BranchData>> = Vec::with_capacity(entries.len());
746 for entry in entries.iter() {
747 let handler = Arc::clone(&entry.handler);
748 let payload_clone = Arc::clone(&shared_payload);
749 let plugin_id = entry.plugin_ref.id();
750 // Snapshot the plugin's local_state and the canonical global_state.
751 // Concurrent plugins do not merge back — each task owns its copy.
752 let mut ctx = ctx_table.snapshot_context(plugin_id);
753 let plugin_name = entry.plugin_ref.name().to_string();
754
755 // Filter per plugin — each may have different capabilities.
756 // Read-only, no write tokens. Wrap in Arc for 'static spawn.
757 let capabilities: std::collections::HashSet<String> = entry
758 .plugin_ref
759 .trusted_config()
760 .capabilities
761 .iter()
762 .cloned()
763 .collect();
764 let filtered = Arc::new(filter_extensions(extensions, &capabilities));
765
766 branches.push(Box::pin(async move {
767 match handler.invoke(&**payload_clone, &filtered, &mut ctx).await {
768 Ok(result_box) => match extract_erased(result_box) {
769 Some(erased) if !erased.continue_processing => {
770 let violation = erased.violation.map(|mut v| {
771 v.plugin_name = Some(plugin_name);
772 v
773 });
774 BranchData::Deny(violation)
775 },
776 // `Some(..)` with continue_processing=true, OR
777 // `None` (downcast failed — historically logged
778 // and treated as Allow) both fall through.
779 _ => BranchData::Allow,
780 },
781 Err(e) => BranchData::Error(e),
782 }
783 }));
784 }
785
786 let cfg = BranchConfig {
787 timeout_per_branch: Some(timeout_dur),
788 short_circuit_on_deny: self.config.short_circuit_on_deny,
789 };
790
791 // `is_deny` halts on explicit Deny only. It can't halt on
792 // Error/Timeout/Panic because the predicate sees only the
793 // value, not the branch index, so it can't read the per-entry
794 // `on_error` policy. Halting on those failures is handled in
795 // the post-loop: the first Fail-policy failure becomes the
796 // returned violation, and any in-flight tasks drop when the
797 // JoinSet inside `run_branches` goes out of scope.
798 //
799 // The original implementation called `set.abort_all()` on
800 // Fail-class errors too. The behavioural difference: the
801 // post-loop now waits for all branches to finish (or hit
802 // their own timeout) before returning. For the slow-plugin
803 // abort test that's fine — that test exercises the Deny
804 // path, which still goes through `is_deny` + abort_all.
805 let outcomes = run_branches(branches, cfg, |v: &BranchData| {
806 matches!(v, BranchData::Deny(_))
807 })
808 .await;
809
810 // Post-loop: walk outcomes in input order applying per-plugin
811 // policy. First halting outcome wins.
812 let mut first_violation: Option<crate::error::PluginViolation> = None;
813
814 for (idx, outcome) in outcomes.into_iter().enumerate() {
815 let entry = &entries[idx];
816 let plugin_name = entry.plugin_ref.name();
817 let on_error = on_error_by_idx[idx];
818
819 match outcome {
820 BranchOutcome::Completed(BranchData::Allow) => {},
821 BranchOutcome::Completed(BranchData::Deny(opt_v)) => {
822 let violation = opt_v.unwrap_or_else(|| {
823 let mut v = crate::error::PluginViolation::new(
824 "concurrent_deny",
825 format!("Plugin '{}' denied", plugin_name),
826 );
827 v.plugin_name = Some(plugin_name.to_string());
828 v
829 });
830 if first_violation.is_none() {
831 first_violation = Some(violation);
832 }
833 },
834 BranchOutcome::Completed(BranchData::Error(e)) => match on_error {
835 OnError::Fail => {
836 if first_violation.is_none() {
837 let mut v = crate::error::PluginViolation::new(
838 "plugin_error",
839 format!("Plugin '{}' failed: {}", plugin_name, e),
840 );
841 v.plugin_name = Some(plugin_name.to_string());
842 first_violation = Some(v);
843 }
844 },
845 OnError::Ignore => {
846 warn!("CONCURRENT plugin '{}' error (ignored): {}", plugin_name, e);
847 errors.push((&*e).into());
848 },
849 OnError::Disable => {
850 warn!("CONCURRENT plugin '{}' disabled after error", plugin_name);
851 errors.push((&*e).into());
852 entry.plugin_ref.disable();
853 },
854 },
855 BranchOutcome::TimedOut => {
856 let timeout_err = crate::error::PluginError::Timeout {
857 plugin_name: plugin_name.to_string(),
858 timeout_ms: timeout_dur.as_millis() as u64,
859 proto_error_code: None,
860 };
861 match on_error {
862 OnError::Fail => {
863 if first_violation.is_none() {
864 let mut v = crate::error::PluginViolation::new(
865 "plugin_timeout",
866 format!("Plugin '{}' timed out", plugin_name),
867 );
868 v.plugin_name = Some(plugin_name.to_string());
869 first_violation = Some(v);
870 }
871 },
872 OnError::Ignore => {
873 warn!("CONCURRENT plugin '{}' timed out (ignored)", plugin_name);
874 errors.push((&timeout_err).into());
875 },
876 OnError::Disable => {
877 warn!("CONCURRENT plugin '{}' disabled after timeout", plugin_name);
878 errors.push((&timeout_err).into());
879 entry.plugin_ref.disable();
880 },
881 }
882 },
883 BranchOutcome::Panicked(s) => {
884 error!("CONCURRENT plugin '{}' task panicked: {}", plugin_name, s);
885 let panic_err = crate::error::PluginError::Execution {
886 plugin_name: plugin_name.to_string(),
887 message: format!("task panicked: {}", s),
888 source: None,
889 code: Some("panic".into()),
890 details: std::collections::HashMap::new(),
891 proto_error_code: None,
892 };
893 match on_error {
894 OnError::Fail => {
895 if first_violation.is_none() {
896 let mut v = crate::error::PluginViolation::new(
897 "plugin_panic",
898 format!("Plugin '{}' task panicked: {}", plugin_name, s),
899 );
900 v.plugin_name = Some(plugin_name.to_string());
901 first_violation = Some(v);
902 }
903 },
904 OnError::Ignore => {
905 warn!("CONCURRENT plugin '{}' panicked (ignored)", plugin_name);
906 errors.push((&panic_err).into());
907 },
908 OnError::Disable => {
909 warn!("CONCURRENT plugin '{}' disabled after panic", plugin_name);
910 errors.push((&panic_err).into());
911 entry.plugin_ref.disable();
912 },
913 }
914 },
915 BranchOutcome::Aborted => {
916 // Cancelled because an earlier branch hit a halt
917 // condition under short_circuit_on_deny. Intentional
918 // — no error to record.
919 },
920 }
921 }
922
923 first_violation
924 }
925
926 // -----------------------------------------------------------------------
927 // Phase 5: Fire-and-Forget (background, no await)
928 // -----------------------------------------------------------------------
929
930 /// Spawn fire-and-forget handlers as background tasks.
931 ///
932 /// Each handler runs in its own `tokio::spawn` — the pipeline does
933 /// not wait for them. Errors and timeouts are logged but have no
934 /// effect on the pipeline result.
935 ///
936 /// Returns the plugin name and join handle for each spawned task
937 /// so they can be stored on `PipelineResult` for optional awaiting
938 /// via `wait_for_background_tasks()`.
939 fn spawn_fire_and_forget(
940 &self,
941 entries: &[HookEntry],
942 payload: &dyn PluginPayload,
943 extensions: &Extensions,
944 ctx_table: &PluginContextTable,
945 task_tracker: &tokio_util::task::TaskTracker,
946 ) -> Vec<(String, tokio::task::JoinHandle<()>)> {
947 if entries.is_empty() {
948 return Vec::new();
949 }
950
951 let timeout_dur = Duration::from_secs(self.config.timeout_seconds);
952
953 let mut handles = Vec::with_capacity(entries.len());
954
955 for entry in entries {
956 let plugin_name = entry.plugin_ref.name().to_string();
957 let handler = Arc::clone(&entry.handler);
958 let owned_payload = payload.clone_boxed();
959 // Snapshot per plugin so fire-and-forget tasks see their stored
960 // local_state from prior hooks, not just an empty context.
961 let mut ctx = ctx_table.snapshot_context(entry.plugin_ref.id());
962 let dur = timeout_dur;
963 let name_for_log = plugin_name.clone();
964
965 // Filter per plugin, read-only, no write tokens
966 let capabilities: std::collections::HashSet<String> = entry
967 .plugin_ref
968 .trusted_config()
969 .capabilities
970 .iter()
971 .cloned()
972 .collect();
973 let filtered = Arc::new(filter_extensions(extensions, &capabilities));
974
975 // Spawn through TaskTracker so `PluginManager::shutdown()`
976 // can drain in-flight fire-and-forget tasks before tearing
977 // down. The returned JoinHandle is the same shape as
978 // tokio::spawn's, so callers using BackgroundTasks still
979 // wait_for_background_tasks() over their own handles.
980 let handle = task_tracker.spawn(async move {
981 let result =
982 timeout(dur, handler.invoke(&*owned_payload, &filtered, &mut ctx)).await;
983
984 match result {
985 Ok(Ok(_)) => {}, // discard
986 Ok(Err(e)) => {
987 warn!(
988 "FIRE_AND_FORGET plugin '{}' error (ignored): {}",
989 name_for_log, e
990 );
991 },
992 Err(_) => {
993 warn!(
994 "FIRE_AND_FORGET plugin '{}' timed out (ignored)",
995 name_for_log
996 );
997 },
998 }
999 });
1000
1001 handles.push((plugin_name, handle));
1002 }
1003
1004 handles
1005 }
1006}
1007
1008impl Default for Executor {
1009 fn default() -> Self {
1010 Self::new(ExecutorConfig::default())
1011 }
1012}
1013
1014// ---------------------------------------------------------------------------
1015// Internal types
1016// ---------------------------------------------------------------------------
1017
1018// SerialResult removed — run_serial_phase now returns Option<Violation> directly.
1019
1020// ---------------------------------------------------------------------------
1021// Erased Result Extraction
1022// ---------------------------------------------------------------------------
1023
1024/// Common fields extracted from a type-erased PluginResult.
1025///
1026/// Handlers return `Box<dyn Any>` which wraps this struct. The
1027/// executor extracts it via [`extract_erased()`] to read the
1028/// control flow fields without knowing the concrete payload type.
1029pub struct ErasedResultFields {
1030 pub continue_processing: bool,
1031 pub modified_payload: Option<Box<dyn PluginPayload>>,
1032 pub modified_extensions: Option<crate::hooks::payload::OwnedExtensions>,
1033 pub violation: Option<crate::error::PluginViolation>,
1034}
1035
1036/// Extract erased result fields from a type-erased handler result.
1037///
1038/// Takes ownership of the Box — the executor consumes the result.
1039/// Logs a warning if the downcast fails (indicates a handler returned
1040/// the wrong type — a framework bug, not a plugin error).
1041pub fn extract_erased(result: Box<dyn Any + Send + Sync>) -> Option<ErasedResultFields> {
1042 match result.downcast::<ErasedResultFields>() {
1043 Ok(b) => Some(*b),
1044 Err(_) => {
1045 warn!("extract_erased: downcast failed — handler returned unexpected type");
1046 None
1047 },
1048 }
1049}
1050
1051/// Convert a typed `PluginResult<P>` into `ErasedResultFields`.
1052///
1053/// Called by `TypedHandlerAdapter` to bridge between the typed
1054/// result and the executor's type-erased dispatch.
1055pub fn erase_result<P: crate::hooks::PluginPayload>(
1056 result: crate::hooks::PluginResult<P>,
1057) -> Box<dyn Any + Send + Sync> {
1058 Box::new(ErasedResultFields {
1059 continue_processing: result.continue_processing,
1060 modified_payload: result
1061 .modified_payload
1062 .map(|p| Box::new(p) as Box<dyn PluginPayload>),
1063 modified_extensions: result.modified_extensions,
1064 violation: result.violation,
1065 })
1066}
1067
1068#[cfg(test)]
1069mod tests {
1070 use super::*;
1071 use crate::hooks::payload::PluginPayload;
1072 use crate::hooks::PluginResult;
1073
1074 #[derive(Debug, Clone)]
1075 #[allow(dead_code)] // test fixture — typed shape is the point, not field reads
1076 struct TestPayload {
1077 value: String,
1078 }
1079 crate::impl_plugin_payload!(TestPayload);
1080
1081 #[test]
1082 fn test_erase_result_allow() {
1083 let result: PluginResult<TestPayload> = PluginResult::allow();
1084 let erased = erase_result(result);
1085 let fields = extract_erased(erased).unwrap();
1086 assert!(fields.continue_processing);
1087 assert!(fields.violation.is_none());
1088 assert!(fields.modified_payload.is_none());
1089 }
1090
1091 #[test]
1092 fn test_erase_result_deny() {
1093 let result: PluginResult<TestPayload> =
1094 PluginResult::deny(crate::error::PluginViolation::new("test", "denied"));
1095 let erased = erase_result(result);
1096 let fields = extract_erased(erased).unwrap();
1097 assert!(!fields.continue_processing);
1098 assert_eq!(fields.violation.as_ref().unwrap().code, "test");
1099 }
1100
1101 #[test]
1102 fn test_erase_result_modify_payload() {
1103 let result: PluginResult<TestPayload> = PluginResult::modify_payload(TestPayload {
1104 value: "modified".into(),
1105 });
1106 let erased = erase_result(result);
1107 let fields = extract_erased(erased).unwrap();
1108 assert!(fields.continue_processing);
1109 assert!(fields.modified_payload.is_some());
1110 }
1111
1112 #[test]
1113 fn test_erase_result_modify_extensions() {
1114 let mut security = crate::extensions::SecurityExtension::default();
1115 security.add_label("PII");
1116 let ext = Extensions {
1117 security: Some(Arc::new(security)),
1118 ..Default::default()
1119 };
1120 let owned = ext.cow_copy();
1121 let result: PluginResult<TestPayload> = PluginResult::modify_extensions(owned);
1122 let erased = erase_result(result);
1123 let fields = extract_erased(erased).unwrap();
1124 assert!(fields.continue_processing);
1125 assert!(fields.modified_extensions.is_some());
1126 let sec = fields
1127 .modified_extensions
1128 .as_ref()
1129 .unwrap()
1130 .security
1131 .as_ref()
1132 .unwrap();
1133 assert!(sec.has_label("PII"));
1134 }
1135
1136 #[test]
1137 fn test_pipeline_result_allowed() {
1138 let payload: Box<dyn PluginPayload> = Box::new(TestPayload {
1139 value: "test".into(),
1140 });
1141 let result =
1142 PipelineResult::allowed_with(payload, Extensions::default(), PluginContextTable::new());
1143 assert!(result.continue_processing);
1144 assert!(result.modified_payload.is_some());
1145 assert!(result.violation.is_none());
1146 }
1147
1148 #[test]
1149 fn test_pipeline_result_denied() {
1150 let violation = crate::error::PluginViolation::new("test", "denied");
1151 let result =
1152 PipelineResult::denied(violation, Extensions::default(), PluginContextTable::new());
1153 assert!(!result.continue_processing);
1154 assert!(result.modified_payload.is_none());
1155 assert!(result.violation.is_some());
1156 }
1157
1158 #[tokio::test]
1159 async fn test_executor_empty_entries() {
1160 let executor = Executor::default();
1161 let tracker = tokio_util::task::TaskTracker::new();
1162 let payload: Box<dyn PluginPayload> = Box::new(TestPayload {
1163 value: "test".into(),
1164 });
1165 let (result, _) = executor
1166 .execute(&[], payload, Extensions::default(), None, &tracker)
1167 .await;
1168 assert!(result.continue_processing);
1169 assert!(result.modified_payload.is_some());
1170 }
1171}