Skip to content

fix(local stop): stop the server's process group, not just the npx wrapper - #15

Merged
vikasiwp merged 3 commits into
mainfrom
fix/local-stop-pgroup
Oct 12, 2026
Merged

vikasiwp merged 3 commits into
mainfrom
fix/local-stop-pgroup

Conversation

@vikasiwp

@vikasiwp vikasiwp commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

Problem

Without a global @wp-playground/cli the CLI runs Playground via npx. instawp local stop printed "Stopped", but the server kept listening, and the next local start failed with EADDRINUSE.

Root cause

startServerBackground records child.pid. Under npx that pid is npm exec, and the server is two levels below it: npm exec → sh -c wp-playground-cli … → node …/wp-playground-cli server --port=…. stopServer sent SIGTERM to the recorded pid only, so the server survived.

Fix (src/lib/local-env.ts)

  • Kill the group. The server is already spawned detached, so the recorded pid leads its own process group. local stop signals -pid, falling back to the pid alone if it doesn't lead a group. On Windows it runs taskkill /T /F.
  • Wait, then escalate. It waits up to 5s, sends SIGKILL, then waits up to 2s more. When the command returns, the port is free.
  • Check the group is still ours. The pid file survives crashes and reboots, so the pid may now belong to something else, such as a terminal shell. Before signalling, the CLI runs ps -wweo pgid=,args= and needs a process in that group whose arguments include this instance's --port=. If none does, it deletes the pid file and signals nothing. -ww matters: on macOS, ps cuts lines at about 80 columns when not attached to a terminal, which would hide --port=. If ps fails, the CLI trusts the pid file, as before.
  • Orphans. isServerRunning checks the whole group, so a server whose wrapper died still shows as running and can be stopped.
  • Bad pid files. A pid file containing 0, 1 or garbage is discarded. Before, 0 made process.kill(0) signal the CLI's own process group.

Evidence

  • End to end on real Playground (npx path, @wp-playground/cli via npx, Linux, Node 22):
    • local create --background, then SIGTERM only the recorded pid (the old behaviour): the wp-playground-cli node process survives and port 9400 still answers HTTP 302. This is the bug.
    • With the new code: local list shows running. local stop takes 0.66s and the port is free (curl 000).
    • local start --background succeeds, local stop again, and no Playground processes are left.
  • src/__tests__/local-env-stop.test.ts, 8 tests. Each one fails when the mechanism it covers is removed:
Mutation Tests that fail
Signal the pid only, not the group stop, orphan, SIGKILL
No wait loop stop
isServerRunning checks the pid only orphan
No SIGKILL SIGKILL-ignoring server
Ownership check always true recycled pid
ps without -ww COLUMNS=40 truncation
No pgid filter marker in another group
  • Full suite: 502 tests pass. api-exec.test.ts fails to load in this sandbox, which is pre-existing and unrelated.
  • Not run on Windows or macOS. The taskkill /T path and BSD ps come from reading the code, not from a run.

Reviews: code-reviewer (agent) found 3 issues across rounds 1 and 2. All are fixed in d92d13b and 93c5d15.

Agent task: tsk_a97c63295c8e1e05
Agentric session: https://aos.agents.instawp.net/#/sessions/aos-ses_38c321a35dad3faa

Next step (human): /code-review ultra 15

🤖 Generated with Claude Code

…apper

Under the npx fallback the recorded pid is the npx wrapper; SIGTERM to it left
the Playground child alive and holding the port, so the next 'local start' hit
EADDRINUSE. The server is spawned detached, so it leads its own process group:
signal -pid (taskkill /T on Windows), wait up to 5s, then SIGKILL. isServerRunning
checks group liveness so an orphaned server is still stoppable. A pid file of
0/1/garbage is discarded (kill(0) signalled the CLI's own group).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@claude

claude Bot commented Oct 12, 2026 •

Copy link
Copy Markdown

Claude encountered an error —— View job


I'll analyze this and get back to you.

… escalation

Review follow-up. The pid file outlives crashes/reboots, so a recycled pid could
name an unrelated group (an interactive shell leads one) that now gets SIGTERM
then SIGKILL. Check via `ps -eo pgid=,args=` that some process in the group
carries this instance's --port= before signalling; otherwise drop the pid file.
After SIGKILL, wait (<=2s) for the group to vanish so return means port free.
Tests: SIGTERM-ignoring server is SIGKILLed; recycled pid is left alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@claude

claude Bot commented Oct 12, 2026 •

Copy link
Copy Markdown

Claude encountered an error —— View job


I'll analyze this and get back to you.

…d away

Review round 2: ps cuts args to the terminal width (~80 cols on macOS whenever
it is not on a tty; on Linux when COLUMNS is set). Playground's port holder has a
long prefix before --port=, so the ownership check read a live server as
foreign and dropped its pid file: the original bug again. Tests: COLUMNS=40
truncation case; a marker in another process group does not make the pid ours.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@claude

claude Bot commented Oct 12, 2026 •

Copy link
Copy Markdown

Claude encountered an error —— View job


I'll analyze this and get back to you.

@vikasiwp

Copy link
Copy Markdown
Contributor Author

PASS at 93c5d15 (code-reviewer, round 3). Rounds 1 and 2 found 3 issues, all fixed.
src/lib/local-env.ts — 🟡 a stale pid could hit an unrelated process group with SIGKILL. Fix: only signal a group that has a process with this instance's --port= (d92d13b).
src/lib/local-env.ts — 🔴 ps cuts args at about 80 columns (macOS without a tty, Linux with COLUMNS set), which hid --port=. Fix: ps -wweo plus maxBuffer (93c5d15).
src/tests/local-env-stop.test.ts — 🔵 the SIGKILL escalation and the pgid filter were untested. Added tests for both; each fails when its mechanism is removed.
Verified end to end on real Playground via npx: old-style stop leaves the server answering on 9400; the new stop frees the port in 0.66s; start, then stop again, works.
Not run on macOS or Windows. Next step (human): /code-review ultra 15

Comment thread src/lib/local-env.ts
*/
function signalServerTree(pid: number, signal: NodeJS.Signals | 0): boolean {
if (process.platform === 'win32') {
if (signal === 0) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Windows liveness check only checks wrapper PID, not process tree

In signalServerTree, when signal === 0 on Windows, process.kill(pid, 0) only checks if the single recorded PID (the npx wrapper) exists. But the actual kill uses taskkill /T /F which kills the entire process tree. If the npx wrapper dies but the Playground child survives, this liveness check returns false, causing isServerRunning to return false and stopServer to not kill the orphaned server — reproducing the original bug on Windows.

The POSIX path correctly uses process.kill(-pid, 0) to check the process group. On Windows, consider using tasklist or WMI to check the process tree, or accept that the Windows liveness check is best-effort.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

Comment thread src/lib/local-env.ts
// not on a tty, on Linux when COLUMNS is set), which would cut off --port= and
// make a live server look like someone else's.
const ps = spawnSync('ps', ['-wweo', 'pgid=,args='], { encoding: 'utf-8', maxBuffer: 64 << 20 });
if (ps.status !== 0 || typeof ps.stdout !== 'string') return true;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: groupIsOurServer trusts PID file if ps fails

If ps command fails (non-zero exit code or no stdout), the function returns true, trusting the PID file. This could allow signaling an unrelated process if ps is unavailable (e.g., minimal container, busybox ps without -e/-o support) or fails for any reason. Consider logging a warning or at least documenting this fallback behavior.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

Comment thread src/lib/local-env.ts
return false;
}

if (signalServerTree(pid, 'SIGTERM')) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: stopServer returns true even when no process was signaled

If signalServerTree(pid, 'SIGTERM') returns false (process/group already dead), the function skips the wait/escalation but still removes the PID file and returns true. This makes stopServer idempotent but callers cannot distinguish "stopped a running server" from "server was already dead". Consider returning false when nothing was signaled, or document the idempotent behavior explicitly.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

@kilo-code-bot

kilo-code-bot Bot commented Oct 12, 2026 •

Copy link
Copy Markdown

Code Review Summary

Status: 3 Issues Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 0
WARNING 1
SUGGESTION 2
Issue Details (click to expand)

WARNING

File Line Issue
src/lib/local-env.ts 378 Windows liveness check only checks wrapper PID, not process tree

SUGGESTION

File Line Issue
src/lib/local-env.ts 405 groupIsOurServer trusts PID file if ps fails
src/lib/local-env.ts 425 stopServer returns true even when no process was signaled
Files Reviewed (4 files)
  • src/lib/local-env.ts - 3 issues
  • src/__tests__/local-env-stop.test.ts - 0 issues
  • CHANGELOG.md - 0 issues
  • CLAUDE.md - 0 issues

Fix these issues in Kilo Cloud


Reviewed by nemotron-3-ultra-550b-a55b:free · Input: 283.6K · Output: 9.8K · Cached: 436.3K

@vikasiwp

Copy link
Copy Markdown
Contributor Author

QA: PASS ✅
Env: Linux (QA host, Node 22.22) · macOS 14.8 + 26.6 and Windows Server (GitHub runners, Node 22.23) · InstaWP/cli PR #15 @93c5d151ed
Criteria: npx fallback (no global Playground): create --background → stop leaves no listener and no --port= process → start --background without EADDRINUSE → stop again; local list running/stopped at every step; the same with a global @wp-playground/cli.

Platform base 0c67c16 (npx) head npx head global
Linux ❌ port still 302 after "Stopped", listen EADDRINUSE :::9416 ✅ all 15 checks ✅ all 15
Windows ❌ 2/2 runs: cmd→npx-cli→cmd→node tree survives, port still 302 ✅ all 15 (run 2) ✅ 2/2
macOS 14 / 26 ✅ bug does not reproduce ✅ ✅
  • Forced-orphan leg (SIGTERM only the wrapper pid, which is what the old stop did, then local stop): Linux base leaves node …wp-playground-cli --port=9419 answering 302, while head kills it (ECONNREFUSED, no process left). On macOS no orphan forms on either leg.
  • macOS: npm exec runs Playground's node directly, with no sh -c hop as on Linux, so the old SIGTERM already worked there. On macOS this PR is a non-regression, not a fix. BSD ps -wweo pgid=,args= returned the full args with --port= for both group rows in 12/12 polls on 14.8 and 26.6, so groupIsOurServer matches correctly.
  • Windows: taskkill /T takes down all 4 processes.
  • Not caused by this PR: the first Windows head-npx create hit the 120s readiness timeout on a cold npx download; the later legs passed.
  • Nit (non-blocking): local list --json has no status field.

Evidence: runs 38212894689 · 38213251664 · 38213564632 (macOS ps diag). Repro-before-fix: yes, on Linux and Windows.
— QA

@vikasiwp
vikasiwp merged commit 006009f into main Oct 12, 2026
1 of 2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant