diff --git a/RuoYi-Vue/docs/superpowers/plans/2026-06-18-run-preview-npm-install-stall.md b/RuoYi-Vue/docs/superpowers/plans/2026-06-18-run-preview-npm-install-stall.md new file mode 100644 index 0000000..034336b --- /dev/null +++ b/RuoYi-Vue/docs/superpowers/plans/2026-06-18-run-preview-npm-install-stall.md @@ -0,0 +1,365 @@ +# Run Preview npm Install Stall Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make run-preview restart/stop terminate all descendant npm processes and reuse one persistent npm download cache across generated frontends and preview sessions. + +**Architecture:** Wrap Windows preview commands in a no-profile PowerShell process that records its PID, then return a `ManagedPreviewProcess` carrying the PID file alongside the delegated Java `Process`. The killer uses `taskkill /T` for managed processes before its existing Java and port-based fallbacks. `FrontProjectRunPreviewServiceImpl` places one npm cache directly under the configured preview workspace root and passes it to both frontends. + +**Tech Stack:** Java 8, Spring Boot 2.5, JUnit 4, Mockito, Windows PowerShell, npm 6+ + +--- + +## File Structure + +- Create `ruoyi-generator/src/main/java/com/ruoyi/generator/service/front/ManagedPreviewProcess.java`: delegates `Process` operations and exposes the recorded Windows root PID. +- Modify `ruoyi-generator/src/main/java/com/ruoyi/generator/service/front/RunPreviewProcessRunner.java`: launch Windows commands through the PID-recording wrapper. +- Modify `ruoyi-generator/src/main/java/com/ruoyi/generator/service/front/RunPreviewProcessKiller.java`: terminate managed Windows process trees before existing fallbacks. +- Create `ruoyi-generator/src/test/java/com/ruoyi/generator/service/front/RunPreviewProcessKillerTest.java`: regression tests for tree termination and fallback behavior. +- Modify `ruoyi-generator/src/test/java/com/ruoyi/generator/service/front/RunPreviewProcessRunnerTest.java`: verify managed process PID capture with a real short-lived child process. +- Modify `ruoyi-generator/src/main/java/com/ruoyi/generator/service/front/FrontProjectRunPreviewServiceImpl.java`: configure one persistent shared npm cache. +- Modify `ruoyi-generator/src/test/java/com/ruoyi/generator/service/front/FrontProjectRunPreviewServiceImplTest.java`: verify both frontends share that cache and prefer cached packages. + +### Task 1: Managed Windows Process Tree + +**Files:** +- Create: `ruoyi-generator/src/main/java/com/ruoyi/generator/service/front/ManagedPreviewProcess.java` +- Modify: `ruoyi-generator/src/main/java/com/ruoyi/generator/service/front/RunPreviewProcessRunner.java` +- Modify: `ruoyi-generator/src/main/java/com/ruoyi/generator/service/front/RunPreviewProcessKiller.java` +- Test: `ruoyi-generator/src/test/java/com/ruoyi/generator/service/front/RunPreviewProcessKillerTest.java` +- Test: `ruoyi-generator/src/test/java/com/ruoyi/generator/service/front/RunPreviewProcessRunnerTest.java` + +- [ ] **Step 1: Write the failing killer tests** + +Create `RunPreviewProcessKillerTest` with: + +```java +@Test +public void stopRequestsWholeTreeTerminationForManagedProcess() throws Exception +{ + File pidFile = temporaryFolder.newFile("preview.pid"); + Files.write(pidFile.toPath(), "4321".getBytes(StandardCharsets.UTF_8)); + FakeProcess delegate = new FakeProcess(); + ManagedPreviewProcess process = new ManagedPreviewProcess(delegate, pidFile); + RecordingProcessKiller killer = new RecordingProcessKiller(); + + killer.stop(process, null); + + assertEquals(Long.valueOf(4321L), killer.killedPid); + assertTrue(delegate.destroyed); + assertTrue(!pidFile.exists()); +} + +@Test +public void stopFallsBackWhenManagedProcessHasNoPid() throws Exception +{ + File missingPidFile = new File(temporaryFolder.getRoot(), "missing.pid"); + FakeProcess delegate = new FakeProcess(); + ManagedPreviewProcess process = new ManagedPreviewProcess(delegate, missingPidFile); + RecordingProcessKiller killer = new RecordingProcessKiller(); + + killer.stop(process, null); + + assertEquals(null, killer.killedPid); + assertTrue(delegate.destroyed); +} +``` + +The recording killer overrides a production seam: + +```java +@Override +protected boolean killWindowsProcessTree(long pid) +{ + killedPid = Long.valueOf(pid); + return true; +} +``` + +- [ ] **Step 2: Run the killer tests and verify RED** + +Run: + +```text +mvn -pl ruoyi-generator -Dtest=RunPreviewProcessKillerTest test +``` + +Expected: compilation fails because `ManagedPreviewProcess` and `killWindowsProcessTree(long)` do not exist. + +- [ ] **Step 3: Add a failing runner integration test** + +Add a Windows-only test to `RunPreviewProcessRunnerTest`: + +```java +@Test +public void startCommandRecordsManagedWindowsProcessPid() throws Exception +{ + if (!isWindows()) + { + return; + } + File projectDirectory = temporaryFolder.newFolder("managed-process"); + RunPreviewProcessRunner runner = new RunPreviewProcessRunner(); + + Process process = runner.startCommandForTesting( + "ping 127.0.0.1 -n 3 > nul", projectDirectory, new HashMap()); + + try + { + assertTrue(process instanceof ManagedPreviewProcess); + assertTrue(((ManagedPreviewProcess) process).waitForRootPid(2000L) > 0L); + } + finally + { + process.destroyForcibly(); + } +} +``` + +- [ ] **Step 4: Run the runner test and verify RED** + +Run: + +```text +mvn -pl ruoyi-generator -Dtest=RunPreviewProcessRunnerTest test +``` + +Expected: compilation fails because `startCommandForTesting` and `ManagedPreviewProcess` do not exist. + +- [ ] **Step 5: Implement `ManagedPreviewProcess`** + +Create a package-private final class extending `Process`. Delegate all standard stream, wait, exit, destroy, `destroyForcibly`, and `isAlive` methods to the original process. Add: + +```java +long waitForRootPid(long timeoutMillis) +{ + long deadline = System.currentTimeMillis() + timeoutMillis; + do + { + Long pid = readRootPid(); + if (pid != null) + { + return pid.longValue(); + } + sleepQuietly(25L); + } + while (System.currentTimeMillis() < deadline && delegate.isAlive()); + return -1L; +} + +void deletePidFile() +{ + if (pidFile.isFile()) + { + pidFile.delete(); + } +} +``` + +`readRootPid()` reads UTF-8 text, trims it, accepts only a positive `long`, and returns `null` for a missing or invalid file. + +- [ ] **Step 6: Wrap Windows commands in `RunPreviewProcessRunner`** + +Keep the existing shell path on non-Windows. On Windows: + +```java +private Process startWindowsManaged(String shellCommand, File workingDirectory, + Map environment) throws IOException +{ + File pidFile = new File(workingDirectory, ".easycode-process-" + UUID.randomUUID() + ".pid"); + ProcessBuilder builder = new ProcessBuilder( + windowsPowerShellExecutable(), + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-Command", + WINDOWS_PROCESS_WRAPPER); + builder.directory(workingDirectory); + builder.redirectErrorStream(true); + builder.environment().putAll(environment); + builder.environment().put("EASYCODE_PREVIEW_COMMAND", shellCommand); + builder.environment().put("EASYCODE_PREVIEW_PID_FILE", pidFile.getAbsolutePath()); + return new ManagedPreviewProcess(builder.start(), pidFile); +} +``` + +Use this wrapper script: + +```powershell +[System.IO.File]::WriteAllText( + $env:EASYCODE_PREVIEW_PID_FILE, + [string]$PID, + [System.Text.Encoding]::UTF8 +) +& $env:ComSpec /d /s /c $env:EASYCODE_PREVIEW_COMMAND +exit $LASTEXITCODE +``` + +Expose only a package-private `startCommandForTesting(...)` that calls the same private `start(...)` path as production. + +- [ ] **Step 7: Terminate the managed process tree** + +At the start of `RunPreviewProcessKiller.stopProcess`: + +```java +ManagedPreviewProcess managed = process instanceof ManagedPreviewProcess + ? (ManagedPreviewProcess) process + : null; +if (managed != null) +{ + long pid = managed.waitForRootPid(GRACEFUL_WAIT_MILLIS); + if (pid > 0L) + { + killWindowsProcessTree(pid); + waitForExit(process, GRACEFUL_WAIT_MILLIS); + } +} +``` + +Then retain the existing `destroy()` and `destroyForcibly()` fallback. In `finally`, call `managed.deletePidFile()`. + +Implement the overridable OS seam: + +```java +protected boolean killWindowsProcessTree(long pid) +{ + return executeAndWait(Arrays.asList( + "taskkill", "/PID", String.valueOf(pid), "/T", "/F")); +} +``` + +Change the current quiet command helper to return `true` only when the command exits with status `0`; callers that do not care may ignore the result. + +- [ ] **Step 8: Run focused tests and verify GREEN** + +Run: + +```text +mvn -pl ruoyi-generator -Dtest=RunPreviewProcessKillerTest,RunPreviewProcessRunnerTest test +``` + +Expected: both test classes pass with zero failures. + +- [ ] **Step 9: Commit the process-tree fix** + +```text +git add ruoyi-generator/src/main/java/com/ruoyi/generator/service/front/ManagedPreviewProcess.java ruoyi-generator/src/main/java/com/ruoyi/generator/service/front/RunPreviewProcessRunner.java ruoyi-generator/src/main/java/com/ruoyi/generator/service/front/RunPreviewProcessKiller.java ruoyi-generator/src/test/java/com/ruoyi/generator/service/front/RunPreviewProcessKillerTest.java ruoyi-generator/src/test/java/com/ruoyi/generator/service/front/RunPreviewProcessRunnerTest.java +git commit -m "Fix run preview process tree cleanup" +``` + +### Task 2: Persistent Shared npm Cache + +**Files:** +- Modify: `ruoyi-generator/src/main/java/com/ruoyi/generator/service/front/FrontProjectRunPreviewServiceImpl.java` +- Modify: `ruoyi-generator/src/test/java/com/ruoyi/generator/service/front/FrontProjectRunPreviewServiceImplTest.java` + +- [ ] **Step 1: Change service assertions to the desired cache behavior** + +In `startCreatesWorkspaceInitializesDatabaseAndStartsProcesses`, assert: + +```java +assertEquals("true", frontendEnvCaptor.getValue().get("NPM_CONFIG_PREFER_OFFLINE")); +assertEquals("false", frontendEnvCaptor.getValue().get("NPM_CONFIG_OFFLINE")); +assertEquals(".npm-cache", new File(frontendCache).getName()); +assertEquals(new File(status.getWorkspacePath()).getParentFile().getCanonicalFile(), + new File(frontendCache).getParentFile().getCanonicalFile()); +``` + +In `startSupportsCombinedDownloadPackageLayout`, replace the separate-cache assertion with: + +```java +assertEquals(frontendCache, adminFrontendCache); +assertEquals(".npm-cache", new File(frontendCache).getName()); +``` + +- [ ] **Step 2: Run the service test and verify RED** + +Run: + +```text +mvn -pl ruoyi-generator -Dtest=FrontProjectRunPreviewServiceImplTest test +``` + +Expected: failures show separate `frontend` / `admin-frontend` cache paths and `NPM_CONFIG_PREFER_OFFLINE=false`. + +- [ ] **Step 3: Configure one cache under the preview root** + +After creating the workspace, resolve: + +```java +File sharedNpmCache = new File(new File(workspaceRoot).getCanonicalFile(), ".npm-cache"); +``` + +Pass `sharedNpmCache` to `putNpmPreviewEnvironment` for both frontend environments. + +Inside `putNpmPreviewEnvironment`, retain online fallback: + +```java +environment.put("NPM_CONFIG_OFFLINE", "false"); +environment.put("npm_config_offline", "false"); +environment.put("NPM_CONFIG_PREFER_OFFLINE", "true"); +environment.put("npm_config_prefer_offline", "true"); +environment.put("NPM_CONFIG_PREFER_ONLINE", "false"); +environment.put("npm_config_prefer_online", "false"); +``` + +- [ ] **Step 4: Run the service test and verify GREEN** + +Run: + +```text +mvn -pl ruoyi-generator -Dtest=FrontProjectRunPreviewServiceImplTest test +``` + +Expected: all tests in the class pass. + +- [ ] **Step 5: Commit the shared-cache fix** + +```text +git add ruoyi-generator/src/main/java/com/ruoyi/generator/service/front/FrontProjectRunPreviewServiceImpl.java ruoyi-generator/src/test/java/com/ruoyi/generator/service/front/FrontProjectRunPreviewServiceImplTest.java +git commit -m "Reuse npm cache for run previews" +``` + +### Task 3: Regression Verification + +**Files:** +- Verify: all files changed in Tasks 1 and 2 + +- [ ] **Step 1: Run all focused run-preview tests** + +Run: + +```text +mvn -pl ruoyi-generator -Dtest=RunPreviewProcessRunnerTest,RunPreviewProcessKillerTest,FrontProjectRunPreviewServiceImplTest test +``` + +Expected: zero failures and zero errors. + +- [ ] **Step 2: Run the generator module test suite** + +Run: + +```text +mvn -pl ruoyi-generator test +``` + +Expected: `BUILD SUCCESS`. + +- [ ] **Step 3: Inspect the scoped diff** + +Run: + +```text +git diff HEAD~2 -- ruoyi-generator/src/main/java/com/ruoyi/generator/service/front/ManagedPreviewProcess.java ruoyi-generator/src/main/java/com/ruoyi/generator/service/front/RunPreviewProcessRunner.java ruoyi-generator/src/main/java/com/ruoyi/generator/service/front/RunPreviewProcessKiller.java ruoyi-generator/src/main/java/com/ruoyi/generator/service/front/FrontProjectRunPreviewServiceImpl.java ruoyi-generator/src/test/java/com/ruoyi/generator/service/front/RunPreviewProcessRunnerTest.java ruoyi-generator/src/test/java/com/ruoyi/generator/service/front/RunPreviewProcessKillerTest.java ruoyi-generator/src/test/java/com/ruoyi/generator/service/front/FrontProjectRunPreviewServiceImplTest.java +``` + +Expected: only process-tree management, shared-cache configuration, and their tests are present. + +- [ ] **Step 4: Perform a live process-tree smoke test** + +Start a managed Windows command through the runner test helper, verify the PID file appears, invoke `RunPreviewProcessKiller.stop`, and verify the child command is no longer alive. This behavior is covered by the focused integration test and must pass on the current Windows host. + +- [ ] **Step 5: Report the operational cleanup requirement** + +The code fix cannot retroactively attach PID metadata to npm processes started before deployment. Identify any pre-fix preview npm/node processes still running and stop only those process IDs after confirming they belong to directories under `preview-workspaces`.