feat(run): update a Tomcat deployment from compiled classes instead of rebuilding the WAR (#1140)

"Update classes and resources" / "Update resources" on a Tomcat configuration deploying the project's WAR artifact ran a full package build and re-exploded the archive. They now compile the WAR module incrementally through jdtls and copy only the changed classes and resources into WEB-INF/classes and changed web files (src/main/webapp) into the exploded deployment; Tomcat reloads the context, or HotSwap applies the classes when debugging. Falls back to rebuilding the WAR when a dependency module was compiled since the last update (its WEB-INF/lib jar would be stale), the deployment doesn't exist yet, or no Java language server is available. Redeploy still rebuilds the WAR.

Closes #970
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-11-04 09:43:54 +08:00
1 джерело 42968a39b3
коміт 2e6cda1d86
9 files changed
+267 -7

No files matched your search

+40
Переглянути файл
@@ -241,6 +241,35 @@ pub async fn run_tomcat_update(host: &Host, window: &str, config: RunConfig, mod
.map_err(|e| IdeError::internal(e.to_string()))?
}
/// Updates a running Tomcat artifact deployment from the module's compiled output and web
/// resources (`ide_run::tomcat::sync_outputs`). `false`: rebuild the WAR instead.
pub async fn run_tomcat_sync(
host: &Host,
window: &str,
config: RunConfig,
module: std::path::PathBuf,
class_paths: Vec<String>,
resources_only: bool,
) -> IdeResult<bool> {
host.require_trust(window)?;
let project = host.project(window)?;
if !host.processes.list().iter().any(|r| r.running && r.config_id == config.id) {
return Err(IdeError::invalid("Start this Tomcat configuration before updating it"));
}
if config.extras.params.get("deployment").is_some_and(|v| v != "artifact") {
return Ok(false);
}
tokio::task::spawn_blocking(move || {
let module = dunce::canonicalize(project.root.join(module))?;
let class_paths: Vec<_> =
class_paths.iter().filter_map(|p| dunce::canonicalize(project.root.join(p)).ok()).collect();
let base = ide_run::tomcat::base_dir(&project.cache_dir, &config.id);
ide_run::tomcat::sync_outputs(&base, &module, &class_paths, resources_only)
})
.await
.map_err(|e| IdeError::internal(e.to_string()))?
}
/// Commands of this module the remote server answers.
pub(crate) fn register(d: &mut Dispatcher) {
d.add("run_dotnet_resolve", |c| async move {
@@ -255,6 +284,17 @@ pub(crate) fn register(d: &mut Dispatcher) {
d.add("run_tomcat_home", |c| async move { run_tomcat_home(&c.host, &c.window, c.arg("home")?).await });
d.add("run_tomcat_artifacts", |c| async move { run_tomcat_artifacts(&c.host, &c.window).await });
d.add("run_tomcat_build", |c| async move { run_tomcat_build(&c.host, &c.window, c.arg("config")?).await });
d.add("run_tomcat_sync", |c| async move {
run_tomcat_sync(
&c.host,
&c.window,
c.arg("config")?,
c.arg("module")?,
c.arg("classPaths")?,
c.arg("resourcesOnly")?,
)
.await
});
d.add("run_tomcat_update", |c| async move {
run_tomcat_update(&c.host, &c.window, c.arg("config")?, c.arg("mode")?).await
});
+136
Переглянути файл
@@ -203,6 +203,94 @@ pub fn explode(war: &Path, destination: &Path, resources_only: bool) -> IdeResul
remove_stale(destination, destination, &expected, resources_only)
}
/// Updates the exploded artifact deployment from the module's own class output directories and
/// its web resources (`src/main/webapp`), file by file and only what changed, instead of
/// rebuilding and re-exploding the WAR. `module` and `class_paths` are absolute.
/// `Ok(false)`: not possible this way (not deployed from the artifact yet, the module's output
/// isn't on the classpath, or a module it depends on was compiled since the last update, whose
/// archive in `WEB-INF/lib` would be stale) — rebuild the WAR instead.
pub fn sync_outputs(base: &Path, module: &Path, class_paths: &[PathBuf], resources_only: bool) -> IdeResult<bool> {
let dest = base.join("deployment");
let manifest = base.join("synced-files.json");
let since = [base.join("artifact-path.txt"), manifest.clone()]
.iter()
.filter_map(|p| fs::metadata(p).and_then(|m| m.modified()).ok())
.max();
let Some(since) = since else { return Ok(false) };
if !dest.join("WEB-INF").is_dir() {
return Ok(false);
}
let (own, others): (Vec<&PathBuf>, Vec<&PathBuf>) =
class_paths.iter().filter(|p| p.is_dir()).partition(|p| p.starts_with(module));
if own.is_empty() || others.iter().any(|d| modified_since(d, since)) {
return Ok(false);
}
let previous: Vec<String> =
fs::read(&manifest).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default();
let mut current = std::collections::BTreeSet::new();
let classes = dest.join("WEB-INF/classes");
for dir in own {
sync_tree(dir, dir, &classes, Path::new("WEB-INF/classes"), resources_only, &mut current)?;
}
let webapp = module.join("src/main/webapp");
if webapp.is_dir() {
sync_tree(&webapp, &webapp, &dest, Path::new(""), resources_only, &mut current)?;
}
for rel in previous.iter().filter(|p| !current.contains(*p)) {
let path = dest.join(rel);
if path.is_file() {
fs::remove_file(path)?;
}
}
fs::write(manifest, serde_json::to_vec(&current).map_err(invalid)?)?;
Ok(true)
}
fn modified_since(dir: &Path, since: std::time::SystemTime) -> bool {
let Ok(entries) = fs::read_dir(dir) else { return false };
entries.flatten().any(|e| match e.file_type() {
Ok(t) if t.is_dir() => modified_since(&e.path(), since),
Ok(t) if t.is_file() => e.metadata().and_then(|m| m.modified()).is_ok_and(|m| m > since),
_ => false,
})
}
/// Copies changed files of `from` (below `root`) to `to`; unchanged files keep their timestamps,
/// so Tomcat only reloads when a class really changed. Records each source file as `prefix/rel`.
fn sync_tree(
root: &Path,
from: &Path,
to: &Path,
prefix: &Path,
resources_only: bool,
seen: &mut std::collections::BTreeSet<String>,
) -> IdeResult<()> {
for entry in fs::read_dir(from)? {
let entry = entry?;
let path = entry.path();
let kind = entry.file_type()?;
if kind.is_dir() {
sync_tree(root, &path, to, prefix, resources_only, seen)?;
continue;
}
if !kind.is_file() {
continue;
}
let rel = path.strip_prefix(root).map_err(invalid)?;
seen.insert(prefix.join(rel).to_string_lossy().replace('\\', "/"));
if resources_only && rel.extension().is_some_and(|e| e == "class") {
continue;
}
let target = to.join(rel);
let bytes = fs::read(&path)?;
if fs::read(&target).ok().as_deref() != Some(&bytes) {
fs::create_dir_all(target.parent().unwrap_or(to))?;
fs::write(target, bytes)?;
}
}
Ok(())
}
pub fn deployment(root: &Path, base: &Path, config: &RunConfig, resources_only: bool) -> IdeResult<PathBuf> {
let p = &config.extras.params;
match p.get("deployment").map(String::as_str).unwrap_or("artifact") {
@@ -439,6 +527,54 @@ mod tests {
assert!(deployment(tmp.path(), &base, &c, false).unwrap().ends_with("WebContent"));
}
#[test]
fn updates_the_exploded_deployment_from_module_outputs() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let (base, module) = (root.join("base"), root.join("web"));
let (dest, out, dep) =
(base.join("deployment"), module.join("target/classes"), root.join("core/target/classes"));
let webapp = module.join("src/main/webapp");
for dir in [dest.join("WEB-INF/classes/a"), out.join("a"), webapp.clone(), dep.clone()] {
fs::create_dir_all(dir).unwrap();
}
for (path, text) in [
(dest.join("WEB-INF/classes/a/Same.class"), "same"),
(dest.join("WEB-INF/classes/a/App.class"), "old"),
(dest.join("index.jsp"), "old"),
(out.join("a/Same.class"), "same"),
(out.join("a/App.class"), "new"),
(out.join("app.properties"), "new"),
(webapp.join("index.jsp"), "new"),
] {
fs::write(path, text).unwrap();
}
let paths = [out.clone(), dep.clone(), root.join("lib.jar")];
// Not deployed from the artifact yet.
assert!(!sync_outputs(&base, &module, &paths, false).unwrap());
fs::write(base.join("artifact-path.txt"), "web.war").unwrap();
let same = || fs::metadata(dest.join("WEB-INF/classes/a/Same.class")).unwrap().modified().unwrap();
let before = same();
assert!(sync_outputs(&base, &module, &paths, true).unwrap());
assert_eq!(fs::read_to_string(dest.join("WEB-INF/classes/a/App.class")).unwrap(), "old");
assert_eq!(fs::read_to_string(dest.join("WEB-INF/classes/app.properties")).unwrap(), "new");
assert_eq!(fs::read_to_string(dest.join("index.jsp")).unwrap(), "new");
assert!(sync_outputs(&base, &module, &paths, false).unwrap());
assert_eq!(fs::read_to_string(dest.join("WEB-INF/classes/a/App.class")).unwrap(), "new");
assert_eq!(same(), before);
fs::remove_file(out.join("app.properties")).unwrap();
assert!(sync_outputs(&base, &module, &paths, false).unwrap());
assert!(!dest.join("WEB-INF/classes/app.properties").exists());
assert!(dest.join("WEB-INF/classes/a/Same.class").exists());
// A dependency module compiled since: its archive in WEB-INF/lib is stale.
let later = std::time::SystemTime::now() + std::time::Duration::from_secs(60);
fs::File::create(dep.join("Core.class")).unwrap().set_modified(later).unwrap();
assert!(!sync_outputs(&base, &module, &paths, false).unwrap());
}
#[test]
fn deployment_errors_identify_the_checked_path() {
let tmp = tempfile::tempdir().unwrap();
let base = tmp.path().join("cache");
+1
Переглянути файл
@@ -332,6 +332,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
run::run_tomcat_build,
run::run_application_build,
run::run_application_publish,
run::run_tomcat_sync,
run::run_tomcat_update,
run::run_test_reports,
build::build_model,
+12
Переглянути файл
@@ -202,6 +202,18 @@ pub async fn run_tomcat_build(
}
#[tauri::command]
#[specta::specta]
pub async fn run_tomcat_sync(
window: Window,
state: State<'_, AppState>,
config: RunConfig,
module: std::path::PathBuf,
class_paths: Vec<String>,
resources_only: bool,
) -> IdeResult<bool> {
ide_host::ops::run::run_tomcat_sync(&state.host, window.label(), config, module, class_paths, resources_only).await
}
#[tauri::command]
#[specta::specta]
pub async fn run_tomcat_update(
window: Window,
state: State<'_, AppState>,
+1
Переглянути файл
@@ -623,6 +623,7 @@ export const commands = {
}) & (RunExtras_Serialize) | null, IdeError>(__TAURI_INVOKE("run_tomcat_build", { config })),
runApplicationBuild: (config: RunConfig_Deserialize, module: string) => typedError<RunConfig_Serialize, IdeError>(__TAURI_INVOKE("run_application_build", { config, module })),
runApplicationPublish: (config: RunConfig_Deserialize, classPaths: string[], mainClass: string) => typedError<null, IdeError>(__TAURI_INVOKE("run_application_publish", { config, classPaths, mainClass })),
runTomcatSync: (config: RunConfig_Deserialize, module: string, classPaths: string[], resourcesOnly: boolean) => typedError<boolean, IdeError>(__TAURI_INVOKE("run_tomcat_sync", { config, module, classPaths, resourcesOnly })),
runTomcatUpdate: (config: RunConfig_Deserialize, mode: string) => typedError<null, IdeError>(__TAURI_INVOKE("run_tomcat_update", { config, mode })),
/**
* Results from the JUnit XML reports (Surefire, Failsafe, Gradle) of the module at
+14 -1
Переглянути файл
@@ -3,6 +3,7 @@ import type { RunSession } from "./store";
const mocks = vi.hoisted(() => ({
action: vi.fn(async () => true),
build: vi.fn(async () => {}),
sync: vi.fn(async () => false),
update: vi.fn(async () => ({ status: "ok", data: null })),
start: vi.fn(async () => {}),
stop: vi.fn(async () => {}),
@@ -47,7 +48,7 @@ vi.mock("@/platform/javaLaunch", async (original) => ({
resolveJavaModule: async () => "app",
}));
vi.mock("./buildUpdate", () => ({ runUpdateBuild: mocks.runBuild }));
vi.mock("./tomcat", () => ({ buildTomcatArtifact: mocks.build }));
vi.mock("./tomcat", () => ({ buildTomcatArtifact: mocks.build, syncTomcatOutputs: mocks.sync }));
vi.mock("./store", () => ({
startRun: mocks.start,
stopSession: mocks.stop,
@@ -66,6 +67,7 @@ beforeEach(() => {
vi.clearAllMocks();
mocks.debug = true;
mocks.action.mockResolvedValue(true);
mocks.sync.mockResolvedValue(false);
mocks.state.configs = [];
mocks.state.sessions = [];
mocks.state.activeSession = null;
@@ -80,6 +82,17 @@ describe("Tomcat application updates", () => {
expect(mocks.build.mock.invocationCallOrder[0]).toBeLessThan(mocks.update.mock.invocationCallOrder[0]!);
expect(mocks.update.mock.invocationCallOrder[0]).toBeLessThan(mocks.action.mock.invocationCallOrder[0]!);
});
it("copies the compiled outputs into the deployment without rebuilding the WAR when it can", async () => {
mocks.sync.mockResolvedValue(true);
await updateTomcat(session, "classes");
expect(mocks.sync).toHaveBeenCalledWith(session.config, false);
expect(mocks.build).not.toHaveBeenCalled();
expect(mocks.update).not.toHaveBeenCalled();
expect(mocks.action).toHaveBeenCalledWith("debug.reloadChangedClasses", { configId: "tomcat:one" });
await updateTomcat(session, "redeploy");
expect(mocks.build).toHaveBeenCalledWith(session.config);
expect(mocks.update).toHaveBeenCalledWith(session.config, "redeploy");
});
it("does not replace classes when updating only resources", async () => {
await updateTomcat(session, "resources");
expect(mocks.update).toHaveBeenCalledWith(session.config, "resources");
+5 -3
Переглянути файл
@@ -11,7 +11,7 @@ import {
type DebugServiceSession,
} from "@/workbench/contributions";
import { showContextMenu } from "@/ui";
import { buildTomcatArtifact } from "./tomcat";
import { buildTomcatArtifact, syncTomcatOutputs } from "./tomcat";
import { runUpdateBuild } from "./buildUpdate";
import { startRun, stopSession, useRun, waitForExit, type RunSession } from "./store";
import { isUpdatable, updateAllowed, type UpdateMode } from "./updatePolicies";
@@ -86,8 +86,10 @@ export async function updateApplication(session: UpdateSession, mode: string): P
if (session.config.kind === "spring-boot") {
await updateSpringBoot(session.config, mode, debugging);
} else {
await buildTomcatArtifact(session.config);
await call(commands.runTomcatUpdate(session.config, mode));
if (mode === "redeploy" || !(await syncTomcatOutputs(session.config, mode === "resources"))) {
await buildTomcatArtifact(session.config);
await call(commands.runTomcatUpdate(session.config, mode));
}
if (mode === "classes" && debugging) {
const swapped =
getAction("debug.reloadChangedClasses") &&
+37 -2
Переглянути файл
@@ -1,7 +1,7 @@
import { describe, expect, it, vi, afterEach } from "vitest";
import { render, screen, waitFor, cleanup } from "@testing-library/react";
import { newConfigOfType, missingParams } from "./types";
import { tomcatType, tomcatBrowserUrl } from "./tomcat";
import { tomcatType, tomcatBrowserUrl, syncTomcatOutputs } from "./tomcat";
import { jdwpLaunch } from "./javaDebug";
import { TomcatSettings } from "./TomcatSettings";
import { typeRows } from "./AddConfigPopup";
@@ -13,8 +13,16 @@ vi.mock("@/platform/ipc", async (importOriginal) => ({
runTomcatHome: vi.fn(async () => ({ status: "ok", data: { version: "Apache Tomcat/10.1.61" } })),
runTomcatArtifacts: vi.fn(async () => ({
status: "ok",
data: [{ id: "maven:pom.xml", name: "hello", module: "", tool: "maven" }],
data: [
{ id: "maven:pom.xml", name: "hello", module: "", tool: "maven" },
{ id: "maven:web/pom.xml", name: "web", module: "web", tool: "maven" },
],
})),
javaResolveLaunch: vi.fn(async () => ({
status: "ok",
data: { build: "succeeded", classPaths: ["/p/web/target/classes", "/m2/lib.jar"], pathListSeparator: ":" },
})),
runTomcatSync: vi.fn(async () => ({ status: "ok", data: true })),
},
}));
afterEach(cleanup);
@@ -83,3 +91,30 @@ describe("Tomcat Local", () => {
expect(await screen.findByRole("alert")).toHaveTextContent("Invalid Tomcat installation");
});
});
describe("updating a running Tomcat deployment", () => {
const config = (params: Record<string, string>) => ({ ...newConfigOfType(tomcatType, []), id: "t", params });
it("compiles the selected WAR module and copies its outputs instead of rebuilding the WAR", async () => {
const c = config({ deployment: "artifact", artifact: "maven:web/pom.xml" });
expect(await syncTomcatOutputs(c, false)).toBe(true);
expect(commands.javaResolveLaunch).toHaveBeenCalledWith("web", false, true, expect.anything());
expect(commands.runTomcatSync).toHaveBeenCalledWith(c, "web", ["/p/web/target/classes", "/m2/lib.jar"], false);
});
it("leaves WAR files, exploded directories and failed compilations to the WAR path", async () => {
vi.mocked(commands.runTomcatSync).mockClear();
expect(await syncTomcatOutputs(config({ deployment: "war" }), true)).toBe(false);
vi.mocked(commands.javaResolveLaunch).mockResolvedValueOnce({
status: "error",
error: { kind: "unavailable" },
} as never);
expect(await syncTomcatOutputs(config({ deployment: "artifact" }), true)).toBe(false);
expect(commands.runTomcatSync).not.toHaveBeenCalled();
vi.mocked(commands.javaResolveLaunch).mockResolvedValueOnce({
status: "ok",
data: { build: "withErrors", classPaths: [] },
} as never);
await expect(syncTomcatOutputs(config({ deployment: "artifact" }), false)).rejects.toThrow();
});
});
+21 -1
Переглянути файл
@@ -1,4 +1,5 @@
import { call, commands, type RunConfig } from "@/platform/ipc";
import { tx } from "@/platform/i18n";
import { call, commands, nextRequestId, type RunConfig } from "@/platform/ipc";
import type { RunConfigTypeContribution } from "@/workbench/contributions";
import { updatePolicyDefaults, updatePolicyFields } from "./updatePolicies";
import { runUpdateBuild } from "./buildUpdate";
@@ -96,6 +97,25 @@ export async function buildTomcatArtifact(config: RunConfig): Promise<void> {
await runUpdateBuild(build);
}
/**
* Updates a running artifact deployment without rebuilding the WAR: compiles the WAR module
* incrementally and copies its changed classes, resources and web files into the deployment.
* `false` when that isn't possible (other deployment kinds, no Java language server, a changed
* dependency module): the caller rebuilds the WAR.
*/
export async function syncTomcatOutputs(config: RunConfig, resourcesOnly: boolean): Promise<boolean> {
const p = config.params ?? {};
if ((p.deployment ?? "artifact") !== "artifact") return false;
const artifacts = await call(commands.runTomcatArtifacts());
const artifact = p.artifact ? artifacts.find((a) => a.id === p.artifact) : artifacts[0];
if (!artifact) return false;
const info = await call(commands.javaResolveLaunch(artifact.module, false, true, nextRequestId())).catch(() => null);
if (!info) return false;
if (info.build === "failed" || info.build === "withErrors" || info.build === "cancelled")
throw new Error(tx("Application update build failed"));
return call(commands.runTomcatSync(config, artifact.module, info.classPaths, resourcesOnly));
}
export function tomcatBrowserUrl(config: RunConfig): string {
const p = config.params ?? {};
const name =