Share hook configuration across cloned hook views (#53657)

## Why

`with_executor_hooks` clones the hook registry to create an executor-specific view. Copying the configured handlers, plugin sources, and diagnostics on every clone is unnecessary when that configuration is unchanged.

## What changed

Store hook configuration in `Arc`-backed collections and use copy-on-write when replacing executor-scoped handlers. Skip handler mutation when there are no executor hooks to add or remove.

## Testing

Extend the executor hook registration test to verify that updating a cloned engine leaves the previous engine's handlers unchanged.

GitOrigin-RevId: 6b188ca6ca21772ccd7336344d98c023f769834e
This commit is contained in:
jif
2026-10-13 12:51:12 +00:00
committed by copyberry
parent 4e7ef7d19d
commit ab82af0b2c
4 changed files with 51 additions and 34 deletions
@@ -512,9 +512,9 @@ fn write_handler(temp: &TempDir, source: &str) -> ConfiguredHandler {
async fn schedule(runtime: &CommandHookRuntime, handler: ConfiguredHandler, cwd: &Path) {
let engine = ClaudeHooksEngine {
handlers: vec![handler],
warnings: Vec::new(),
required_load_errors: Vec::new(),
handlers: Arc::new(vec![handler]),
warnings: Arc::default(),
required_load_errors: Arc::default(),
command_runtime: runtime.clone(),
mcp_executor: mcp_executor(),
};
+25 -13
View File
@@ -221,9 +221,10 @@ pub struct HookListEntry {
#[derive(Clone)]
pub(crate) struct ClaudeHooksEngine {
pub(crate) handlers: Vec<ConfiguredHandler>,
warnings: Vec<String>,
required_load_errors: Vec<String>,
// Per-turn views share configuration; executor overlays copy handlers on mutation.
pub(crate) handlers: Arc<Vec<ConfiguredHandler>>,
warnings: Arc<[String]>,
required_load_errors: Arc<[String]>,
pub(crate) command_runtime: CommandHookRuntime,
pub(crate) mcp_executor: Arc<dyn HookMcpExecutor>,
}
@@ -240,9 +241,9 @@ impl ClaudeHooksEngine {
) -> Self {
if !enabled && plugin_hook_sources.is_empty() {
return Self {
handlers: Vec::new(),
warnings: Vec::new(),
required_load_errors: Vec::new(),
handlers: Arc::default(),
warnings: Arc::default(),
required_load_errors: Arc::default(),
command_runtime,
mcp_executor,
};
@@ -262,9 +263,9 @@ impl ClaudeHooksEngine {
discovered.required_load_errors.clear();
}
Self {
handlers: discovered.handlers,
warnings: discovered.warnings,
required_load_errors: discovered.required_load_errors,
handlers: Arc::new(discovered.handlers),
warnings: discovered.warnings.into(),
required_load_errors: discovered.required_load_errors.into(),
command_runtime,
mcp_executor,
}
@@ -275,15 +276,26 @@ impl ClaudeHooksEngine {
}
pub(crate) fn set_executor_hooks(&mut self, executor_hooks: Vec<ExecutorPluginHookSource>) {
self.handlers.retain(|handler| {
if executor_hooks.is_empty()
&& !self.handlers.iter().any(|handler| {
matches!(
handler.source_path,
HandlerSourcePath::ExecutorScoped { .. }
)
})
{
return;
}
let handlers = Arc::make_mut(&mut self.handlers);
handlers.retain(|handler| {
!matches!(
handler.source_path,
HandlerSourcePath::ExecutorScoped { .. }
)
});
let mut display_order = self
.handlers
let mut display_order = handlers
.iter()
.map(|handler| handler.display_order)
.max()
@@ -320,7 +332,7 @@ impl ClaudeHooksEngine {
if !seen_targets.insert(target) {
continue;
}
self.handlers.push(ConfiguredHandler {
handlers.push(ConfiguredHandler {
builtin: true,
event_name,
matcher: None,
+18 -13
View File
@@ -115,13 +115,13 @@ fn permission_request_timeout_only_counts_synchronous_handlers() {
..synchronous_handler.clone()
};
engine.handlers = vec![synchronous_handler, asynchronous_handler.clone()];
engine.handlers = Arc::new(vec![synchronous_handler, asynchronous_handler.clone()]);
assert_eq!(
engine.max_permission_request_timeout(),
Duration::from_secs(5)
);
engine.handlers = vec![asynchronous_handler];
engine.handlers = Arc::new(vec![asynchronous_handler]);
assert_eq!(engine.max_permission_request_timeout(), Duration::ZERO);
}
@@ -1960,7 +1960,7 @@ fn builtin_cleanup_ignores_disablement_but_preserves_managed_only_policy() {
}),
mcp_executor(),
);
assert_eq!(feature_disabled.handlers, expected);
assert_eq!(feature_disabled.handlers.as_ref(), &expected);
}
}
@@ -1996,7 +1996,10 @@ fn disabled_hooks_feature_keeps_builtin_cleanup_but_not_trusted_plugin_hooks() {
}),
mcp_executor(),
);
assert_eq!(engine.handlers, vec![discovered.handlers[0].clone()]);
assert_eq!(
engine.handlers.as_slice(),
&[discovered.handlers[0].clone()]
);
}
#[tokio::test]
@@ -2310,8 +2313,8 @@ fn executor_stop_hook_fixture() -> (
};
engine.set_executor_hooks(vec![source.clone()]);
assert_eq!(
engine.handlers,
vec![ConfiguredHandler {
engine.handlers.as_slice(),
&[ConfiguredHandler {
builtin: true,
event_name: HookEventName::Stop,
matcher: None,
@@ -2400,7 +2403,7 @@ async fn executor_stop_hooks_run_unless_regular_hooks_block_without_stopping() {
vec![expected_executor_call.clone()]
);
engine.handlers.push(ConfiguredHandler {
Arc::make_mut(&mut engine.handlers).push(ConfiguredHandler {
builtin: false,
event_name: HookEventName::Stop,
matcher: None,
@@ -2435,7 +2438,7 @@ async fn executor_stop_hooks_run_unless_regular_hooks_block_without_stopping() {
]
);
engine.handlers.push(ConfiguredHandler {
Arc::make_mut(&mut engine.handlers).push(ConfiguredHandler {
builtin: false,
event_name: HookEventName::Stop,
matcher: None,
@@ -2493,7 +2496,8 @@ async fn executor_stop_hooks_run_unless_regular_hooks_block_without_stopping() {
#[test]
fn executor_stop_hooks_register_each_target_environment_once() {
let (mut engine, _, _, _, first_source) = executor_stop_hook_fixture();
let mut expected_handlers = engine.handlers.clone();
let previous = engine.clone();
let mut expected_handlers = engine.handlers.as_ref().clone();
let mut duplicate_target = first_source.clone();
duplicate_target.plugin_id = PluginId::parse("chrome@openai-bundled").expect("valid plugin ID");
let mut second_source = first_source.clone();
@@ -2508,7 +2512,8 @@ fn executor_stop_hooks_register_each_target_environment_once() {
expected_handlers.push(second_handler);
engine.set_executor_hooks(vec![first_source, duplicate_target, second_source]);
assert_eq!(engine.handlers, expected_handlers);
assert_eq!(engine.handlers.as_ref(), &expected_handlers);
assert_eq!(previous.handlers.as_slice(), &expected_handlers[..1]);
}
#[tokio::test]
@@ -2558,7 +2563,7 @@ fn executor_hooks_register_events_from_each_environment() {
let (mut engine, _, _, _, mut first_source) = executor_stop_hook_fixture();
first_source.hooks.interrupt = std::mem::take(&mut first_source.hooks.stop);
engine.set_executor_hooks(vec![first_source.clone()]);
let mut expected_handlers = engine.handlers.clone();
let mut expected_handlers = engine.handlers.as_ref().clone();
let mut second_source = first_source.clone();
second_source.environment_id = "executor-b".to_string();
second_source.hooks.stop = std::mem::take(&mut second_source.hooks.interrupt);
@@ -2574,7 +2579,7 @@ fn executor_hooks_register_events_from_each_environment() {
engine.set_executor_hooks(vec![first_source, second_source]);
assert_eq!(engine.handlers, expected_handlers);
assert_eq!(engine.handlers.as_ref(), &expected_handlers);
}
#[tokio::test]
@@ -2624,7 +2629,7 @@ async fn memory_consolidation_stop_preserves_policy_and_executor_cleanup() {
tool: policy_call.tool.clone(),
input: policy_call.input.clone(),
};
engine.handlers.push(handler);
Arc::make_mut(&mut engine.handlers).push(handler);
assert_eq!(
engine.preview_stop(&request).len(),
usize::from(runs_policy)
+5 -5
View File
@@ -61,10 +61,10 @@ pub struct Hooks {
// TODO: Once legacy `notify` is removed, capture this snapshot in `CommandHookRuntime::new`
// and remove the environment plumbing from `Hooks` and `from_config`.
environment: Arc<Vec<(OsString, OsString)>>,
after_agent: Vec<Hook>,
after_agent: Arc<[Hook]>,
engine: ClaudeHooksEngine,
plugin_hook_sources: Vec<PluginHookSource>,
plugin_hook_load_warnings: Vec<String>,
plugin_hook_sources: Arc<[PluginHookSource]>,
plugin_hook_load_warnings: Arc<[String]>,
}
impl Hooks {
@@ -144,8 +144,8 @@ impl Hooks {
environment,
after_agent,
engine,
plugin_hook_sources: config.plugin_hook_sources,
plugin_hook_load_warnings: config.plugin_hook_load_warnings,
plugin_hook_sources: config.plugin_hook_sources.into(),
plugin_hook_load_warnings: config.plugin_hook_load_warnings.into(),
}
}