fix: serialize scoped command teardown

This commit is contained in:
2026-09-06 03:29:50 +09:00
parent 0f8d61188a
commit 7b1cf854f2
+175 -12
View File
@@ -10,9 +10,11 @@ use fs_operation::{
};
use tokio::sync::broadcast;
const MAX_SCOPED_COMMANDS: usize = 16;
use crate::{
CommandEvent, CommandHandle, CommandOutput, CommandOutputRequest, CommandRequest,
CommandSnapshot, CommandStatus, Workdir, WorkdirError, WorkdirSession,
CommandSnapshot, CommandStatus, CommandStream, Workdir, WorkdirError, WorkdirSession,
WorkdirSessionCapabilities, WorkdirSessionCapability, WorkdirSessionHandle,
};
@@ -69,12 +71,15 @@ impl WorkdirToolBroker {
validity: SessionValidity::root(),
child_write_leases: Mutex::new(HashMap::new()),
next_lease_id: AtomicU64::new(1),
close_lock: Arc::new(tokio::sync::Mutex::new(())),
owned_commands: Arc::new(Mutex::new(HashSet::new())),
pending_command_events: Arc::new(Mutex::new(HashMap::new())),
starting_tool_calls: Arc::new(Mutex::new(HashSet::new())),
forwarded_terminals: Arc::new(Mutex::new(HashSet::new())),
command_events,
closes_source: true,
#[cfg(test)]
command_start_gate: Mutex::new(None),
});
Self {
session: authority.clone(),
@@ -181,6 +186,7 @@ impl WorkdirScopeLease {
output.status,
output.exit_code,
output.next_cursor.unwrap_or(output.content.len()) as u64,
&output.content,
);
self.broker
.authority
@@ -196,6 +202,7 @@ impl WorkdirScopeLease {
CommandStatus::Cancelled,
None,
0,
"",
);
self.broker
.authority
@@ -289,6 +296,12 @@ struct ActiveWriteLease {
rules: Vec<WorkdirToolScopeRule>,
}
#[cfg(test)]
struct TestCommandStartGate {
entered: tokio::sync::Notify,
release: tokio::sync::Notify,
}
struct ScopedWorkdirSession {
source: WorkdirSessionHandle,
cwd: FsPath,
@@ -297,12 +310,15 @@ struct ScopedWorkdirSession {
validity: Arc<SessionValidity>,
child_write_leases: Mutex<HashMap<u64, ActiveWriteLease>>,
next_lease_id: AtomicU64,
close_lock: Arc<tokio::sync::Mutex<()>>,
owned_commands: Arc<Mutex<HashSet<String>>>,
pending_command_events: Arc<Mutex<HashMap<String, Vec<CommandEvent>>>>,
starting_tool_calls: Arc<Mutex<HashSet<String>>>,
forwarded_terminals: Arc<Mutex<HashSet<String>>>,
command_events: broadcast::Sender<CommandEvent>,
closes_source: bool,
#[cfg(test)]
command_start_gate: Mutex<Option<Arc<TestCommandStartGate>>>,
}
impl std::fmt::Debug for ScopedWorkdirSession {
@@ -416,19 +432,33 @@ impl ScopedWorkdirSession {
status: CommandStatus,
exit_code: Option<i32>,
offset: u64,
fallback_output: &str,
) {
publish_owned_command_event(
&self.command_events,
&self.forwarded_terminals,
CommandEvent::Terminal {
let mut terminals = self
.forwarded_terminals
.lock()
.expect("forwarded terminal command mutex poisoned");
if !terminals.insert(command_id.to_string()) {
return;
}
if !fallback_output.is_empty() {
let _ = self.command_events.send(CommandEvent::Output {
command_id: command_id.to_string(),
stream: CommandStream::Stdout,
start_offset: 0,
end_offset: fallback_output.len() as u64,
content: fallback_output.to_string(),
observed_at_ms: unix_timestamp_ms(),
});
}
let _ = self.command_events.send(CommandEvent::Terminal {
command_id: command_id.to_string(),
status,
exit_code,
stdout_end_offset: offset,
stderr_end_offset: 0,
observed_at_ms: unix_timestamp_ms(),
},
);
});
}
fn ensure_parent_write_available(&self, path: &FsPath) -> Result<(), WorkdirError> {
@@ -656,6 +686,7 @@ impl ScopedWorkdirSession {
command_events.clone(),
)
.map(|handle| Arc::new(Mutex::new(Some(handle))));
let close_lock = Arc::new(tokio::sync::Mutex::new(()));
let child = Arc::new(ScopedWorkdirSession {
source: self.source.clone(),
cwd: request.cwd,
@@ -664,12 +695,15 @@ impl ScopedWorkdirSession {
validity: validity.clone(),
child_write_leases: Mutex::new(HashMap::new()),
next_lease_id: AtomicU64::new(1),
close_lock: close_lock.clone(),
owned_commands,
pending_command_events,
starting_tool_calls,
forwarded_terminals,
command_events,
closes_source: false,
#[cfg(test)]
command_start_gate: Mutex::new(None),
});
let broker = WorkdirToolBroker {
session: child.clone(),
@@ -681,7 +715,7 @@ impl ScopedWorkdirSession {
capabilities,
validity,
cleanup_pending,
close_lock: Arc::new(tokio::sync::Mutex::new(())),
close_lock,
})
}
}
@@ -749,7 +783,36 @@ impl WorkdirSession for ScopedWorkdirSession {
&self,
mut request: CommandRequest,
) -> Result<CommandHandle, WorkdirError> {
let _admission_guard = self.close_lock.lock().await;
// Command is an explicit capability, not a typed path mutation. We
// intentionally keep an ancestor's Command capability available while
// a child holds a write scope; only typed Write/Edit operations use the
// best-effort overlapping-path guard below.
self.ensure_command()?;
#[cfg(test)]
{
let gate = self
.command_start_gate
.lock()
.expect("command start gate mutex poisoned")
.clone();
if let Some(gate) = gate {
gate.entered.notify_one();
gate.release.notified().await;
}
}
if self.scope.is_some()
&& self
.owned_commands
.lock()
.expect("scoped command set mutex poisoned")
.len()
>= MAX_SCOPED_COMMANDS
{
return Err(WorkdirError::Unavailable(format!(
"scoped command limit of {MAX_SCOPED_COMMANDS} is reached"
)));
}
let tool_call_id = request.tool_call_id.clone();
if self.scope.is_some() {
request.cwd = Some(match request.cwd.as_ref() {
@@ -831,6 +894,7 @@ impl WorkdirSession for ScopedWorkdirSession {
output.status,
output.exit_code,
output.next_cursor.unwrap_or(output.content.len()) as u64,
&output.content,
);
self.owned_commands
.lock()
@@ -1036,14 +1100,20 @@ fn publish_owned_command_event(
forwarded_terminals: &Mutex<HashSet<String>>,
event: CommandEvent,
) {
if let CommandEvent::Terminal { command_id, .. } = &event
&& !forwarded_terminals
let command_id = command_event_id(&event);
let mut terminals = forwarded_terminals
.lock()
.expect("forwarded terminal command mutex poisoned")
.insert(command_id.clone())
.expect("forwarded terminal command mutex poisoned");
match &event {
CommandEvent::Terminal { .. } if !terminals.insert(command_id.to_string()) => return,
CommandEvent::Started { .. } | CommandEvent::Output { .. }
if terminals.contains(command_id) =>
{
return;
}
_ => {}
}
drop(terminals);
let _ = sender.send(event);
}
@@ -1592,6 +1662,99 @@ mod tests {
child.close().await.unwrap();
}
#[tokio::test]
async fn scoped_command_ceiling_rejects_the_seventeenth_live_command() {
let root = TempDir::new().unwrap();
fs::create_dir_all(root.path().join("work")).unwrap();
let parent = session(root.path());
let child = parent
.scope(request("work", WorkdirToolScopePermission::Write))
.await
.unwrap();
for index in 0..MAX_SCOPED_COMMANDS {
child
.start_command(CommandRequest {
command: "sleep 30".into(),
timeout_secs: 60,
output_limit: 1024,
cwd: None,
spill_dir: None,
tool_call_id: Some(format!("command-{index}")),
})
.await
.unwrap();
}
let error = child
.start_command(CommandRequest {
command: "sleep 30".into(),
timeout_secs: 60,
output_limit: 1024,
cwd: None,
spill_dir: None,
tool_call_id: Some("command-over-limit".into()),
})
.await
.unwrap_err();
assert!(matches!(error, WorkdirError::Unavailable(message) if message.contains("limit")));
child.close().await.unwrap();
}
#[tokio::test]
async fn close_serializes_with_inflight_command_admission() {
let root = TempDir::new().unwrap();
fs::create_dir_all(root.path().join("work")).unwrap();
let parent = session(root.path());
let child = Arc::new(
parent
.scope(request("work", WorkdirToolScopePermission::Write))
.await
.unwrap(),
);
let gate = Arc::new(TestCommandStartGate {
entered: tokio::sync::Notify::new(),
release: tokio::sync::Notify::new(),
});
*child.broker.authority.command_start_gate.lock().unwrap() = Some(gate.clone());
let entered = gate.entered.notified();
let command_child = child.clone();
let command = tokio::spawn(async move {
command_child
.start_command(CommandRequest {
command: "sleep 30".into(),
timeout_secs: 60,
output_limit: 1024,
cwd: None,
spill_dir: None,
tool_call_id: Some("racing-command".into()),
})
.await
});
entered.await;
let close_child = child.clone();
let mut close = tokio::spawn(async move { close_child.close().await });
assert!(
tokio::time::timeout(std::time::Duration::from_millis(50), &mut close)
.await
.is_err(),
"close must wait for command admission to commit or fail"
);
gate.release.notify_one();
command.await.unwrap().unwrap();
close.await.unwrap().unwrap();
assert!(!child.is_active());
assert!(
child
.broker
.authority
.owned_commands
.lock()
.unwrap()
.is_empty()
);
}
#[tokio::test]
async fn closing_scope_cancels_and_terminalizes_owned_commands() {
let root = TempDir::new().unwrap();