fix: fence recursive SubWorker shutdown

This commit is contained in:
2026-09-06 03:48:54 +09:00
parent 7b1cf854f2
commit ca5fddf89b
4 changed files with 217 additions and 36 deletions
+5 -3
View File
@@ -1101,14 +1101,16 @@ where
"manage Workdir tools require Backend Workspace API authority", "manage Workdir tools require Backend Workspace API authority",
)); ));
} }
let child_registry = spawned_registry.clone(); let shutdown_registry = spawned_registry.clone();
let reopen_registry = spawned_registry.clone();
feature_registry.add_module( feature_registry.add_module(
crate::feature::builtin::manage_workdir::ManageWorkdirFeature::with_before_workdir_release( crate::feature::builtin::manage_workdir::ManageWorkdirFeature::with_child_lifecycle(
workspace_client, workspace_client,
Arc::new(move || { Arc::new(move || {
let child_registry = child_registry.clone(); let child_registry = shutdown_registry.clone();
Box::pin(async move { child_registry.shutdown_internal().await }) Box::pin(async move { child_registry.shutdown_internal().await })
}), }),
Arc::new(move || reopen_registry.reopen_internal()),
), ),
); );
} }
@@ -56,6 +56,7 @@ const ATTACH_DESCRIPTION: &str = "Attach this Worker to one existing Workdir. Th
const DETACH_DESCRIPTION: &str = "Detach this Worker from its active Workdir and release Workdir occupancy. Any ephemeral operation session is closed."; const DETACH_DESCRIPTION: &str = "Detach this Worker from its active Workdir and release Workdir occupancy. Any ephemeral operation session is closed.";
pub(crate) type BeforeWorkdirRelease = pub(crate) type BeforeWorkdirRelease =
Arc<dyn Fn() -> Pin<Box<dyn Future<Output = std::io::Result<()>> + Send>> + Send + Sync>; Arc<dyn Fn() -> Pin<Box<dyn Future<Output = std::io::Result<()>> + Send>> + Send + Sync>;
pub(crate) type AfterWorkdirAttach = Arc<dyn Fn() + Send + Sync>;
const DELETE_DESCRIPTION: &str = "Request removal of one persistent Workdir by id through durable Backend Workspace authority. The input includes only the Workdir id and a bounded reason. The result reports removed, retained, or attention_required without exposing operation-table or provider internals."; const DELETE_DESCRIPTION: &str = "Request removal of one persistent Workdir by id through durable Backend Workspace authority. The input includes only the Workdir id and a bounded reason. The result reports removed, retained, or attention_required without exposing operation-table or provider internals.";
@@ -63,6 +64,7 @@ const DELETE_DESCRIPTION: &str = "Request removal of one persistent Workdir by i
pub struct ManageWorkdirFeature { pub struct ManageWorkdirFeature {
client: Arc<dyn WorkspaceClient>, client: Arc<dyn WorkspaceClient>,
before_workdir_release: Option<BeforeWorkdirRelease>, before_workdir_release: Option<BeforeWorkdirRelease>,
after_workdir_attach: Option<AfterWorkdirAttach>,
} }
impl std::fmt::Debug for ManageWorkdirFeature { impl std::fmt::Debug for ManageWorkdirFeature {
@@ -80,16 +82,19 @@ impl ManageWorkdirFeature {
Self { Self {
client, client,
before_workdir_release: None, before_workdir_release: None,
after_workdir_attach: None,
} }
} }
pub(crate) fn with_before_workdir_release( pub(crate) fn with_child_lifecycle(
client: Arc<dyn WorkspaceClient>, client: Arc<dyn WorkspaceClient>,
before_workdir_release: BeforeWorkdirRelease, before_workdir_release: BeforeWorkdirRelease,
after_workdir_attach: AfterWorkdirAttach,
) -> Self { ) -> Self {
Self { Self {
client, client,
before_workdir_release: Some(before_workdir_release), before_workdir_release: Some(before_workdir_release),
after_workdir_attach: Some(after_workdir_attach),
} }
} }
} }
@@ -110,8 +115,10 @@ impl FeatureModule for ManageWorkdirFeature {
} }
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> { fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
let backend = WorkspaceHttpWorkdirBackend::new(self.client.clone()) let backend = WorkspaceHttpWorkdirBackend::new(self.client.clone()).with_child_lifecycle(
.with_before_workdir_release(self.before_workdir_release.clone()); self.before_workdir_release.clone(),
self.after_workdir_attach.clone(),
);
for (name, definition) in [ for (name, definition) in [
( (
LIST_TOOL, LIST_TOOL,
@@ -176,6 +183,7 @@ impl FeatureModule for ManageWorkdirFeature {
struct WorkspaceHttpWorkdirBackend { struct WorkspaceHttpWorkdirBackend {
client: Arc<dyn WorkspaceClient>, client: Arc<dyn WorkspaceClient>,
before_workdir_release: Option<BeforeWorkdirRelease>, before_workdir_release: Option<BeforeWorkdirRelease>,
after_workdir_attach: Option<AfterWorkdirAttach>,
} }
impl std::fmt::Debug for WorkspaceHttpWorkdirBackend { impl std::fmt::Debug for WorkspaceHttpWorkdirBackend {
@@ -371,14 +379,17 @@ impl WorkspaceHttpWorkdirBackend {
Self { Self {
client, client,
before_workdir_release: None, before_workdir_release: None,
after_workdir_attach: None,
} }
} }
fn with_before_workdir_release( fn with_child_lifecycle(
mut self, mut self,
before_workdir_release: Option<BeforeWorkdirRelease>, before_workdir_release: Option<BeforeWorkdirRelease>,
after_workdir_attach: Option<AfterWorkdirAttach>,
) -> Self { ) -> Self {
self.before_workdir_release = before_workdir_release; self.before_workdir_release = before_workdir_release;
self.after_workdir_attach = after_workdir_attach;
self self
} }
@@ -557,9 +568,17 @@ impl Tool for WorkspaceHttpWorkdirTool {
parse_input::<WorkdirCreateInput>(input_json)?, parse_input::<WorkdirCreateInput>(input_json)?,
ctx.call_id.to_string(), ctx.call_id.to_string(),
), ),
WorkdirOperation::Attach => self WorkdirOperation::Attach => {
let result = self
.backend .backend
.attach(parse_input::<WorkdirAttachInput>(input_json)?), .attach(parse_input::<WorkdirAttachInput>(input_json)?);
if result.is_ok()
&& let Some(after_attach) = &self.backend.after_workdir_attach
{
after_attach();
}
result
}
WorkdirOperation::Detach => { WorkdirOperation::Detach => {
let _input = parse_input::<WorkdirDetachInput>(input_json)?; let _input = parse_input::<WorkdirDetachInput>(input_json)?;
if let Some(before_release) = &self.backend.before_workdir_release { if let Some(before_release) = &self.backend.before_workdir_release {
@@ -1338,7 +1357,7 @@ mod tests {
}); });
let tool = WorkspaceHttpWorkdirTool { let tool = WorkspaceHttpWorkdirTool {
backend: WorkspaceHttpWorkdirBackend::new(client.clone()) backend: WorkspaceHttpWorkdirBackend::new(client.clone())
.with_before_workdir_release(Some(before_release)), .with_child_lifecycle(Some(before_release), None),
operation: WorkdirOperation::Detach, operation: WorkdirOperation::Detach,
}; };
@@ -1361,7 +1380,7 @@ mod tests {
Arc::new(|| Box::pin(async { Err(std::io::Error::other("child cleanup failed")) })); Arc::new(|| Box::pin(async { Err(std::io::Error::other("child cleanup failed")) }));
let tool = WorkspaceHttpWorkdirTool { let tool = WorkspaceHttpWorkdirTool {
backend: WorkspaceHttpWorkdirBackend::new(client.clone()) backend: WorkspaceHttpWorkdirBackend::new(client.clone())
.with_before_workdir_release(Some(before_release)), .with_child_lifecycle(Some(before_release), None),
operation: WorkdirOperation::Detach, operation: WorkdirOperation::Detach,
}; };
@@ -1373,4 +1392,32 @@ mod tests {
assert!(error.to_string().contains("stop Internal SubWorkers")); assert!(error.to_string().contains("stop Internal SubWorkers"));
assert!(client.requests().is_empty()); assert!(client.requests().is_empty());
} }
#[tokio::test]
async fn successful_attach_reopens_internal_subworker_admission() {
let client = Arc::new(RecordingWorkspaceClient::new(vec![response(json!({
"workspace_id": "workspace/test",
"workdir_id": "wd-attached",
"attached": true
}))]));
let reopen_calls = Arc::new(AtomicUsize::new(0));
let reopen_calls_for_hook = reopen_calls.clone();
let after_attach: AfterWorkdirAttach = Arc::new(move || {
reopen_calls_for_hook.fetch_add(1, Ordering::SeqCst);
});
let tool = WorkspaceHttpWorkdirTool {
backend: WorkspaceHttpWorkdirBackend::new(client)
.with_child_lifecycle(None, Some(after_attach)),
operation: WorkdirOperation::Attach,
};
tool.execute(
r#"{"workdir_id":"wd-attached"}"#,
ToolExecutionContext::default(),
)
.await
.unwrap();
assert_eq!(reopen_calls.load(Ordering::SeqCst), 1);
}
} }
+135 -10
View File
@@ -72,6 +72,7 @@ pub(crate) struct InternalSpawnedWorkerRecord {
#[cfg(test)] #[cfg(test)]
pub installed_tools: Arc<[String]>, pub installed_tools: Arc<[String]>,
pub session: InternalWorkerSessionHandle, pub session: InternalWorkerSessionHandle,
pub child_registry: Arc<SpawnedWorkerRegistry>,
change_tracker: Option<tools::Tracker>, change_tracker: Option<tools::Tracker>,
started_at: Instant, started_at: Instant,
stop_lock: Arc<tokio::sync::Mutex<()>>, stop_lock: Arc<tokio::sync::Mutex<()>>,
@@ -89,6 +90,7 @@ impl InternalSpawnedWorkerRecord {
workdir_tool_scope: WorkdirScopeLease, workdir_tool_scope: WorkdirScopeLease,
#[cfg(test)] installed_tools: Vec<String>, #[cfg(test)] installed_tools: Vec<String>,
session: InternalWorkerSessionHandle, session: InternalWorkerSessionHandle,
child_registry: Arc<SpawnedWorkerRegistry>,
change_tracker: Option<tools::Tracker>, change_tracker: Option<tools::Tracker>,
) -> Self { ) -> Self {
Self { Self {
@@ -98,6 +100,7 @@ impl InternalSpawnedWorkerRecord {
#[cfg(test)] #[cfg(test)]
installed_tools: installed_tools.into(), installed_tools: installed_tools.into(),
session, session,
child_registry,
change_tracker, change_tracker,
started_at: Instant::now(), started_at: Instant::now(),
stop_lock: Arc::new(tokio::sync::Mutex::new(())), stop_lock: Arc::new(tokio::sync::Mutex::new(())),
@@ -235,18 +238,39 @@ pub(crate) struct InternalSpawnReservation {
} }
impl InternalSpawnReservation { impl InternalSpawnReservation {
pub(crate) fn commit(mut self, record: InternalSpawnedWorkerRecord) -> io::Result<()> { pub(crate) fn commit(
mut self,
record: InternalSpawnedWorkerRecord,
) -> Result<(), (io::Error, InternalSpawnedWorkerRecord)> {
if record.worker_name != self.worker_name { if record.worker_name != self.worker_name {
return Err(io::Error::new( return Err((
io::Error::new(
io::ErrorKind::InvalidInput, io::ErrorKind::InvalidInput,
"internal SubWorker reservation name does not match record name", "internal SubWorker reservation name does not match record name",
),
record,
)); ));
} }
self.registry let mut records = match self.registry.internal_records.lock() {
.internal_records Ok(records) => records,
.lock() Err(_) => {
.map_err(|_| io::Error::other("internal spawned-worker registry lock poisoned"))? return Err((
.push(record.clone()); io::Error::other("internal spawned-worker registry lock poisoned"),
record,
));
}
};
if self.registry.internal_shutting_down.load(Ordering::Acquire) {
return Err((
io::Error::new(
io::ErrorKind::Interrupted,
"internal SubWorker registry is shutting down",
),
record,
));
}
records.push(record.clone());
drop(records);
self.registry.start_protocol_forwarding(record); self.registry.start_protocol_forwarding(record);
self.committed = true; self.committed = true;
Ok(()) Ok(())
@@ -267,6 +291,7 @@ pub struct SpawnedWorkerRegistry {
internal_records: std::sync::Mutex<Vec<InternalSpawnedWorkerRecord>>, internal_records: std::sync::Mutex<Vec<InternalSpawnedWorkerRecord>>,
service_records: std::sync::Mutex<Vec<InternalServiceWorkerRecord>>, service_records: std::sync::Mutex<Vec<InternalServiceWorkerRecord>>,
internal_names: std::sync::Mutex<HashSet<String>>, internal_names: std::sync::Mutex<HashSet<String>>,
internal_shutting_down: AtomicBool,
parent_scope: Option<SharedScope>, parent_scope: Option<SharedScope>,
parent_protocol: Mutex<Option<(broadcast::Sender<Event>, String)>>, parent_protocol: Mutex<Option<(broadcast::Sender<Event>, String)>>,
} }
@@ -283,6 +308,7 @@ impl SpawnedWorkerRegistry {
internal_records: std::sync::Mutex::new(Vec::new()), internal_records: std::sync::Mutex::new(Vec::new()),
service_records: std::sync::Mutex::new(Vec::new()), service_records: std::sync::Mutex::new(Vec::new()),
internal_names: std::sync::Mutex::new(HashSet::new()), internal_names: std::sync::Mutex::new(HashSet::new()),
internal_shutting_down: AtomicBool::new(false),
parent_scope: None, parent_scope: None,
parent_protocol: Mutex::new(None), parent_protocol: Mutex::new(None),
}) })
@@ -294,6 +320,7 @@ impl SpawnedWorkerRegistry {
internal_records: std::sync::Mutex::new(Vec::new()), internal_records: std::sync::Mutex::new(Vec::new()),
service_records: std::sync::Mutex::new(Vec::new()), service_records: std::sync::Mutex::new(Vec::new()),
internal_names: std::sync::Mutex::new(HashSet::new()), internal_names: std::sync::Mutex::new(HashSet::new()),
internal_shutting_down: AtomicBool::new(false),
parent_scope: None, parent_scope: None,
parent_protocol: Mutex::new(None), parent_protocol: Mutex::new(None),
}) })
@@ -304,6 +331,7 @@ impl SpawnedWorkerRegistry {
internal_records: std::sync::Mutex::new(Vec::new()), internal_records: std::sync::Mutex::new(Vec::new()),
service_records: std::sync::Mutex::new(Vec::new()), service_records: std::sync::Mutex::new(Vec::new()),
internal_names: std::sync::Mutex::new(HashSet::new()), internal_names: std::sync::Mutex::new(HashSet::new()),
internal_shutting_down: AtomicBool::new(false),
parent_scope: Some(parent_scope), parent_scope: Some(parent_scope),
parent_protocol: Mutex::new(None), parent_protocol: Mutex::new(None),
}) })
@@ -383,6 +411,7 @@ impl SpawnedWorkerRegistry {
internal_records: std::sync::Mutex::new(Vec::new()), internal_records: std::sync::Mutex::new(Vec::new()),
service_records: std::sync::Mutex::new(Vec::new()), service_records: std::sync::Mutex::new(Vec::new()),
internal_names: std::sync::Mutex::new(HashSet::new()), internal_names: std::sync::Mutex::new(HashSet::new()),
internal_shutting_down: AtomicBool::new(false),
parent_scope, parent_scope,
parent_protocol: Mutex::new(None), parent_protocol: Mutex::new(None),
}), }),
@@ -394,6 +423,12 @@ impl SpawnedWorkerRegistry {
self: &Arc<Self>, self: &Arc<Self>,
worker_name: String, worker_name: String,
) -> io::Result<InternalSpawnReservation> { ) -> io::Result<InternalSpawnReservation> {
if self.internal_shutting_down.load(Ordering::Acquire) {
return Err(io::Error::new(
io::ErrorKind::Interrupted,
"internal SubWorker registry is shutting down",
));
}
let mut names = self let mut names = self
.internal_names .internal_names
.lock() .lock()
@@ -702,6 +737,7 @@ impl SpawnedWorkerRegistry {
let Some(record) = self.get_internal(name) else { let Some(record) = self.get_internal(name) else {
return Ok(false); return Ok(false);
}; };
Box::pin(record.child_registry.shutdown_internal()).await?;
record record
.workdir_tool_scope .workdir_tool_scope
.close() .close()
@@ -711,13 +747,17 @@ impl SpawnedWorkerRegistry {
} }
pub(crate) async fn shutdown_internal(&self) -> io::Result<()> { pub(crate) async fn shutdown_internal(&self) -> io::Result<()> {
let names = self let names = {
let records = self
.internal_records .internal_records
.lock() .lock()
.expect("internal Worker registry lock poisoned") .map_err(|_| io::Error::other("internal Worker registry lock poisoned"))?;
self.internal_shutting_down.store(true, Ordering::Release);
records
.iter() .iter()
.map(|record| record.worker_name.clone()) .map(|record| record.worker_name.clone())
.collect::<Vec<_>>(); .collect::<Vec<_>>()
};
let mut first_error = None; let mut first_error = None;
for name in names { for name in names {
if let Err(error) = self.remove_internal(&name).await { if let Err(error) = self.remove_internal(&name).await {
@@ -727,6 +767,10 @@ impl SpawnedWorkerRegistry {
first_error.map_or(Ok(()), Err) first_error.map_or(Ok(()), Err)
} }
pub(crate) fn reopen_internal(&self) {
self.internal_shutting_down.store(false, Ordering::Release);
}
/// Stop one direct Internal SubWorker and discard its registry/scope state. /// Stop one direct Internal SubWorker and discard its registry/scope state.
/// ///
/// The child actor must acknowledge its stop before the registry is removed. /// The child actor must acknowledge its stop before the registry is removed.
@@ -753,6 +797,7 @@ impl SpawnedWorkerRegistry {
.stop() .stop()
.await .await
.map_err(|error| io::Error::other(error.to_string()))?; .map_err(|error| io::Error::other(error.to_string()))?;
Box::pin(record.child_registry.shutdown_internal()).await?;
record record
.workdir_tool_scope .workdir_tool_scope
.close() .close()
@@ -1021,6 +1066,7 @@ mod tests {
delegation, delegation,
Vec::new(), Vec::new(),
session, session,
registry(),
None, None,
), ),
sender, sender,
@@ -1276,6 +1322,85 @@ mod tests {
assert!(registry.get_internal("second").is_none()); assert!(registry.get_internal("second").is_none());
} }
#[tokio::test]
async fn shutdown_rejects_new_reservations_until_reopened() {
let registry = registry();
registry.shutdown_internal().await.unwrap();
assert!(registry.reserve_internal_name("late-child".into()).is_err());
registry.reopen_internal();
let reservation = registry.reserve_internal_name("late-child".into()).unwrap();
drop(reservation);
}
#[tokio::test]
async fn concurrent_commit_and_shutdown_leave_no_live_internal_worker() {
let registry = registry();
let reservation = registry
.reserve_internal_name("racing-child".into())
.unwrap();
let (record, _events) =
record("racing-child", InternalWorkerVisibility::ParentClient).await;
let scope = record.workdir_tool_scope.clone();
let barrier = Arc::new(std::sync::Barrier::new(2));
let commit_barrier = barrier.clone();
let commit = tokio::task::spawn_blocking(move || {
commit_barrier.wait();
reservation.commit(record)
});
let shutdown_registry = registry.clone();
let shutdown = tokio::spawn(async move {
barrier.wait();
shutdown_registry.shutdown_internal().await
});
let commit = commit.await.unwrap();
shutdown.await.unwrap().unwrap();
if let Err((_error, record)) = commit {
record.session.stop().await.unwrap();
record.child_registry.shutdown_internal().await.unwrap();
record.workdir_tool_scope.close().await.unwrap();
}
assert!(registry.list_internal().is_empty());
assert!(!scope.is_active());
}
#[tokio::test]
async fn shutdown_fences_a_reservation_that_has_not_committed() {
let registry = registry();
let reservation = registry
.reserve_internal_name("racing-child".into())
.unwrap();
let (record, _events) =
record("racing-child", InternalWorkerVisibility::ParentClient).await;
registry.shutdown_internal().await.unwrap();
let (error, record) = reservation.commit(record).unwrap_err();
assert_eq!(error.kind(), io::ErrorKind::Interrupted);
record.session.stop().await.unwrap();
record.child_registry.shutdown_internal().await.unwrap();
record.workdir_tool_scope.close().await.unwrap();
}
#[tokio::test]
async fn shutdown_recursively_stops_grandchildren_before_parent_scope_release() {
let registry = registry();
let (child, _child_events) = record("child", InternalWorkerVisibility::ParentClient).await;
let child_registry = child.child_registry.clone();
let (grandchild, _grandchild_events) =
record("grandchild", InternalWorkerVisibility::ParentClient).await;
let grandchild_scope = grandchild.workdir_tool_scope.clone();
install_record(&child_registry, grandchild);
install_record(&registry, child);
registry.shutdown_internal().await.unwrap();
assert!(registry.list_internal().is_empty());
assert!(child_registry.list_internal().is_empty());
assert!(!grandchild_scope.is_active());
}
#[tokio::test] #[tokio::test]
async fn running_worker_is_stopped_before_removal() { async fn running_worker_is_stopped_before_removal() {
let registry = registry(); let registry = registry();
+15 -8
View File
@@ -600,15 +600,19 @@ impl Tool for SubWorkerSpawnTool {
), ),
body.to_string(), body.to_string(),
); );
let response = self let response = match self.workspace_context.client().execute(request) {
.workspace_context Ok(response) => response,
.client() Err(error) => {
.execute(request) let _ = session.stop().await;
.map_err(|error| { let _ = workdir_scope.close().await;
ToolError::ExecutionFailed(format!("register review capability: {error}")) return Err(ToolError::ExecutionFailed(format!(
})?; "register review capability: {error}"
)));
}
};
if !response.is_success() { if !response.is_success() {
let _ = session.stop().await; let _ = session.stop().await;
let _ = workdir_scope.close().await;
return Err(ToolError::ExecutionFailed(format!( return Err(ToolError::ExecutionFailed(format!(
"register review capability failed with status {}: {}", "register review capability failed with status {}: {}",
response.status, response.body response.status, response.body
@@ -623,10 +627,13 @@ impl Tool for SubWorkerSpawnTool {
#[cfg(test)] #[cfg(test)]
installed_tools, installed_tools,
session.clone(), session.clone(),
child_registry,
child_change_tracker, child_change_tracker,
); );
if let Err(error) = name_reservation.commit(record) { if let Err((error, record)) = name_reservation.commit(record) {
let _ = session.stop().await; let _ = session.stop().await;
let _ = record.child_registry.shutdown_internal().await;
let _ = record.workdir_tool_scope.close().await;
return Err(ToolError::ExecutionFailed(format!( return Err(ToolError::ExecutionFailed(format!(
"register Internal Worker session: {error}" "register Internal Worker session: {error}"
))); )));