Files
yidaima/RuoYi-Vue/docs/superpowers/plans/2026-06-18-run-preview-npm-install-stall.md
2026-06-18 12:16:56 +08:00

14 KiB

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:

@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:

@Override
protected boolean killWindowsProcessTree(long pid)
{
    killedPid = Long.valueOf(pid);
    return true;
}
  • Step 2: Run the killer tests and verify RED

Run:

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:

@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<String, String>());

    try
    {
        assertTrue(process instanceof ManagedPreviewProcess);
        assertTrue(((ManagedPreviewProcess) process).waitForRootPid(2000L) > 0L);
    }
    finally
    {
        process.destroyForcibly();
    }
}
  • Step 4: Run the runner test and verify RED

Run:

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:

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:

private Process startWindowsManaged(String shellCommand, File workingDirectory,
                                    Map<String, String> 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:

[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:

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:

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:

mvn -pl ruoyi-generator -Dtest=RunPreviewProcessKillerTest,RunPreviewProcessRunnerTest test

Expected: both test classes pass with zero failures.

  • Step 9: Commit the process-tree fix
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:

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:

assertEquals(frontendCache, adminFrontendCache);
assertEquals(".npm-cache", new File(frontendCache).getName());
  • Step 2: Run the service test and verify RED

Run:

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:

File sharedNpmCache = new File(new File(workspaceRoot).getCanonicalFile(), ".npm-cache");

Pass sharedNpmCache to putNpmPreviewEnvironment for both frontend environments.

Inside putNpmPreviewEnvironment, retain online fallback:

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:

mvn -pl ruoyi-generator -Dtest=FrontProjectRunPreviewServiceImplTest test

Expected: all tests in the class pass.

  • Step 5: Commit the shared-cache fix
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:

mvn -pl ruoyi-generator -Dtest=RunPreviewProcessRunnerTest,RunPreviewProcessKillerTest,FrontProjectRunPreviewServiceImplTest test

Expected: zero failures and zero errors.

  • Step 2: Run the generator module test suite

Run:

mvn -pl ruoyi-generator test

Expected: BUILD SUCCESS.

  • Step 3: Inspect the scoped diff

Run:

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.