fix(jdbc): stop plugin processes before replacing jdbc directory

This commit is contained in:
ZhengJin
2026-10-14 19:32:58 +08:00
committed by GitHub
parent 89a29a952c
commit a2bcbb5725
5 changed files with 181 additions and 8 deletions
+149 -1
View File
@@ -397,6 +397,8 @@ pub async fn install_jdbc_plugin_with_progress(
let plugin_dir = plugins_root.join("jdbc");
let status_dir = plugin_dir.clone();
progress(AgentProgressEvent::transfer("jdbc-plugin-extract", 0, 0));
// Blocking on purpose: extraction, process scanning, and the kill-wait loop
// all sleep on the thread; see stop_jdbc_plugin_processes (#12004).
tokio::task::spawn_blocking(move || install_jdbc_plugin_zip(&bytes, &plugin_dir))
.await
.map_err(|err| err.to_string())??;
@@ -409,6 +411,8 @@ pub async fn install_jdbc_plugin_from_file(plugins_root: &Path, file_path: &str)
let plugin_dir = plugins_root.join("jdbc");
let status_dir = plugin_dir.clone();
let file_path = file_path.to_string();
// Blocking on purpose: file read, extraction, and process kill-wait all
// sleep on the thread; see stop_jdbc_plugin_processes (#12004).
tokio::task::spawn_blocking(move || {
let bytes = std::fs::read(file_path).map_err(|e| format!("Failed to read file: {e}"))?;
install_jdbc_plugin_zip(&bytes, &plugin_dir)
@@ -422,6 +426,9 @@ pub async fn install_jdbc_plugin_from_file(plugins_root: &Path, file_path: &str)
pub fn uninstall_jdbc_plugin(plugins_root: &Path) -> Result<JdbcPluginStatus, String> {
let plugin_dir = plugins_root.join("jdbc");
// Same file-holding problem as the update path (#12004): a live sidecar
// java.exe keeps bin/ undeletable. Callers run this on the blocking pool.
stop_jdbc_plugin_processes(&plugin_dir)?;
for entry in ["manifest.json", "bin", "lib"] {
let path = plugin_dir.join(entry);
if !path.exists() {
@@ -588,6 +595,90 @@ async fn download_jdbc_plugin_zip_with_progress(progress: &impl Fn(AgentProgress
Ok(bytes)
}
/// Processes whose executable or any command-line argument points inside the
/// JDBC plugin directory. The Java sidecar runs `-jar <plugin_dir>\lib\dbx-jdbc-plugin.jar`,
/// and its cmd.exe launcher runs the `.bat` inside the same directory. Path
/// normalization is shared with the agent-driver process matcher
/// (`agent_service::normalized_process_path`).
#[cfg_attr(not(windows), allow(dead_code))]
fn jdbc_process_uses_plugin_dir(
executable: Option<&std::path::Path>,
command: &[std::ffi::OsString],
plugin_dir: &Path,
) -> bool {
let normalized_dir = crate::agent_service::normalized_process_path(plugin_dir);
if let Some(executable) = executable {
if crate::agent_service::process_path_is_within(
&crate::agent_service::normalized_process_path(executable),
&normalized_dir,
) {
return true;
}
}
command.iter().any(|argument| {
crate::agent_service::process_path_is_within(
&crate::agent_service::normalized_process_path(std::path::Path::new(argument)),
&normalized_dir,
)
})
}
/// Stop every process holding files inside the JDBC plugin directory before
/// the directory is replaced. A `dbx-mcp.exe` spawned by an external AI tool
/// probes `mcp/tools` on this plugin, which leaves a `java.exe` running with
/// `dbx-jdbc-plugin.jar` open; that process belongs to no session this host
/// can reach, so updates fail with access denied until it is killed (#12004).
/// POSIX replaces in-use files anyway, so this is a no-op there; on Windows
/// the kill mirrors what `stop_driver_processes_before_replacement` does for
/// managed agent drivers. Callers run this on the blocking pool.
fn stop_jdbc_plugin_processes(plugin_dir: &Path) -> Result<(), String> {
#[cfg(windows)]
{
use sysinfo::{get_current_pid, ProcessRefreshKind, ProcessesToUpdate, RefreshKind, System, UpdateKind};
let refresh = ProcessRefreshKind::new().with_cmd(UpdateKind::Always).with_exe(UpdateKind::Always);
let mut system = System::new_with_specifics(RefreshKind::new().with_processes(refresh));
system.refresh_processes_specifics(ProcessesToUpdate::All, true, refresh);
let current_pid = get_current_pid().ok();
let mut matched = Vec::new();
for (pid, process) in system.processes() {
if current_pid == Some(*pid) || !jdbc_process_uses_plugin_dir(process.exe(), process.cmd(), plugin_dir) {
continue;
}
let name = process.name().to_string_lossy().into_owned();
let sent = process.kill();
log::info!(
"Stopping external JDBC plugin process before replacing {}: pid={}, name={}, kill_sent={sent}",
plugin_dir.display(),
pid.as_u32(),
name
);
matched.push((*pid, name));
}
if matched.is_empty() {
return Ok(());
}
for _ in 0..30 {
std::thread::sleep(std::time::Duration::from_millis(100));
system.refresh_processes_specifics(ProcessesToUpdate::All, true, refresh);
matched.retain(|(pid, _)| system.process(*pid).is_some());
if matched.is_empty() {
return Ok(());
}
}
let remaining = matched
.into_iter()
.map(|(pid, name)| format!("{name} (PID {})", pid.as_u32()))
.collect::<Vec<_>>()
.join(", ");
Err(format!("Failed to stop JDBC plugin process(es) holding {}: {remaining}", plugin_dir.display()))
}
#[cfg(not(windows))]
{
let _ = plugin_dir;
Ok(())
}
}
fn install_jdbc_plugin_zip(bytes: &[u8], plugin_dir: &Path) -> Result<(), String> {
let cursor = std::io::Cursor::new(bytes);
let mut archive = zip::ZipArchive::new(cursor).map_err(|err| err.to_string())?;
@@ -646,8 +737,19 @@ fn install_jdbc_plugin_zip(bytes: &[u8], plugin_dir: &Path) -> Result<(), String
if drivers_dir.exists() && !temp_drivers_dir.exists() {
copy_dir_all(&drivers_dir, &temp_drivers_dir)?;
}
// Stop every process running from the plugin directory — including
// sidecars started by other DBX processes (dbx-mcp.exe, dbx.exe) that this
// process cannot reach through any session handle — before its files are
// replaced: on Windows a live java.exe holds dbx-jdbc-plugin.jar open and
// `remove_dir_all` fails with access denied (#12004). A kill failure is
// appended to the removal error below so the user sees both the surviving
// holder process and the OS reason.
let stop_error = stop_jdbc_plugin_processes(plugin_dir).err();
if plugin_dir.exists() {
std::fs::remove_dir_all(plugin_dir).map_err(|err| err.to_string())?;
std::fs::remove_dir_all(plugin_dir).map_err(|err| match &stop_error {
Some(stop_error) => format!("{stop_error}; additionally failed to remove {}: {err}", plugin_dir.display()),
None => err.to_string(),
})?;
}
std::fs::rename(&temp_dir, plugin_dir).map_err(|err| err.to_string())?;
@@ -1300,6 +1402,52 @@ mod tests {
assert!(!is_safe_bundle_id("../hive"));
}
#[test]
fn jdbc_process_matches_java_sidecar_from_plugin_dir() {
let plugin_dir = std::path::PathBuf::from(r"C:\Users\alice\AppData\Roaming\com.dbx.app\plugins\jdbc");
let jar_argument = std::ffi::OsString::from(
r"c:\users\alice\appdata\roaming\com.dbx.app\plugins\jdbc\bin\..\lib\dbx-jdbc-plugin.jar",
);
// The MCP-spawned java.exe holds the plugin jar open via its command line.
assert!(jdbc_process_uses_plugin_dir(
Some(std::path::Path::new(r"C:\Program Files\Java\bin\java.exe")),
&[std::ffi::OsString::from("-jar"), jar_argument.clone()],
&plugin_dir
));
// The cmd.exe launcher runs the .bat inside the plugin directory.
assert!(jdbc_process_uses_plugin_dir(
Some(std::path::Path::new(r"C:\Windows\System32\cmd.exe")),
&[
std::ffi::OsString::from("/e:ON"),
std::ffi::OsString::from(
r#""C:\Users\alice\AppData\Roaming\com.dbx.app\plugins\jdbc\bin\dbx-jdbc-plugin.bat""#
),
],
&plugin_dir
));
// A `\\?\`-prefixed executable inside the directory still matches.
assert!(jdbc_process_uses_plugin_dir(
Some(std::path::Path::new(
r"\\?\C:\Users\alice\AppData\Roaming\com.dbx.app\plugins\jdbc\bin\dbx-jdbc-plugin.exe"
)),
&[],
&plugin_dir
));
// An unrelated java process must be left alone.
assert!(!jdbc_process_uses_plugin_dir(
Some(std::path::Path::new(r"C:\Program Files\Java\bin\java.exe")),
&[std::ffi::OsString::from("-jar"), std::ffi::OsString::from(r"C:\other\agent.jar")],
&plugin_dir
));
// A sibling directory (jdbc-2) must not match the jdbc prefix.
assert!(!jdbc_process_uses_plugin_dir(
Some(std::path::Path::new(r"C:\Users\alice\AppData\Roaming\com.dbx.app\plugins\jdbc-2\bin\run.exe")),
&[],
&plugin_dir
));
}
fn resolved_artifact(group_id: &str, artifact_id: &str, version: &str) -> MavenResolvedArtifact {
MavenResolvedArtifact {
group_id: group_id.to_string(),
+7 -2
View File
@@ -61,14 +61,19 @@ impl ManagedAgentProcessTarget {
}
}
/// Normalize a process path for Windows matching: unify slashes, strip the
/// `\\?\` prefix and quotes, lowercase. Shared by every "is this process
/// holding our files" check (agent drivers, JREs, the JDBC plugin directory).
#[cfg_attr(not(any(windows, test)), allow(dead_code))]
fn normalized_process_path(path: &Path) -> String {
pub fn normalized_process_path(path: &Path) -> String {
let normalized = path.to_string_lossy().trim_matches('"').replace('/', "\\");
normalized.strip_prefix(r"\\?\").unwrap_or(&normalized).trim_end_matches('\\').to_ascii_lowercase()
}
/// Whether `candidate` equals `directory` or is a child path of it, on
/// normalized Windows form. Shared by the agent/JRE/JDBC process matcher.
#[cfg_attr(not(any(windows, test)), allow(dead_code))]
fn process_path_is_within(candidate: &str, directory: &str) -> bool {
pub fn process_path_is_within(candidate: &str, directory: &str) -> bool {
candidate == directory || candidate.strip_prefix(directory).is_some_and(|remainder| remainder.starts_with('\\'))
}
+11 -2
View File
@@ -296,8 +296,17 @@ pub async fn import_agents_from_zip(
})
.await
.map_err(AppError::from)?;
dbx_core::jdbc::import_offline_jdbc_payload(state.app.plugins.root_dir(), &package_path)
.map_err(AppError::from)?;
let plugins_root = state.app.plugins.root_dir().to_path_buf();
// Blocking on purpose: the embedded plugin install scans and waits
// for processes holding the JDBC plugin directory (#12004); the
// blocking pool absorbs it.
let package_path = package_path.clone();
let import = tokio::task::spawn_blocking(move || {
dbx_core::jdbc::import_offline_jdbc_payload(&plugins_root, &package_path)
})
.await
.map_err(|err| AppError::internal(err.to_string()))?;
import.map_err(AppError::from)?;
Ok::<_, AppError>(import_result)
}
.await;
+7 -2
View File
@@ -162,8 +162,13 @@ pub async fn install_jdbc_plugin_local(
}
pub async fn uninstall_jdbc_plugin(State(state): State<Arc<WebState>>) -> Result<Json<JdbcPluginStatus>, AppError> {
let root = state.app.plugins.root_dir();
Ok(Json(jdbc::uninstall_jdbc_plugin(root).map_err(AppError::internal)?))
let root = state.app.plugins.root_dir().to_path_buf();
// Blocking on purpose: the process scan and kill-wait loop before removal
// sleep on the thread (#12004); the blocking pool absorbs it.
let status = tokio::task::spawn_blocking(move || jdbc::uninstall_jdbc_plugin(&root))
.await
.map_err(|err| AppError::internal(err.to_string()))?;
Ok(Json(status.map_err(AppError::internal)?))
}
// ---- System Fonts ----
+7 -1
View File
@@ -231,7 +231,13 @@ pub async fn import_agents_from_zip(
emit_agent_progress(&app_handle, &operation_id, event)
})
.await?;
dbx_core::jdbc::import_offline_jdbc_payload(state.plugins.root_dir(), &package_path)?;
// The embedded JDBC plugin install scans and waits for processes holding
// the JDBC plugin directory (#12004).
let plugins_root = state.plugins.root_dir().to_path_buf();
run_blocking_agent_task("Failed to import the offline JDBC plugin", move || {
dbx_core::jdbc::import_offline_jdbc_payload(&plugins_root, &package_path)
})
.await?;
let count = result.drivers_installed.len() as u32;
emit_agent_progress(&app, &operation_id, AgentProgressEvent::step("done"));
Ok(serde_json::json!({