Repository navigation
astro dev takes a lock-file pid that is now a thread for a running server: refuses to start, and --force / stop kill the unrelated process #18343
Description
Astro Info
Astro v7.3.8
Node v24.8.0
System Linux (x64)
Package Manager bun
Output static
Adapter none
Integrations @astrojs/sitemap
@astrojs/react
If this issue only occurs in one browser, which browser is a problem?
No response (CLI issue)
Describe the Bug
astro dev decides whether the server recorded in .astro/dev.json is still running with isLockFileProcessAlive (packages/astro/src/core/dev/lockfile.ts):
process.kill(pid, 0)succeeds, thenfind("pid", pid)(find-proc, i.e.ps -p <pid>) is asked for the command line, andreturn processInfo?.cmd === undefined || isAstroCommand(processInfo.cmd);
On Linux, kill(pid, 0) also succeeds for a thread id, but ps -p <tid> lists nothing. processInfo is then undefined, and the lock is treated as a live Astro server.
That is the normal state after a container restart. When the project is on a bind mount, .astro/dev.json outlives the container: a SIGTERM'd astro dev does not remove it (we saw this on 7.3.5). The new container hands out pids from 1 again, so the recorded pid is very often a thread of some other process in the new container. We hit it twice in one week, with pid 260 (a thread of turbo) and pid 7 (a thread of the container's init process).
The result, on every restart until someone deletes the file by hand:
Another astro dev server is already running.
URL: http://localhost:4321/
PID: 3580542
Run `astro dev stop` to stop it, or use `astro dev --force` to replace it.
Both suggested remedies make it worse. astro dev --force (killDevServer) and astro dev stop (stopExistingServer) send SIGTERM, then SIGKILL, to that pid. On Linux, kill() with a thread id signals the whole process the thread belongs to. So they kill an unrelated process; in a container that can be PID 1. In the reproduction below, --force kills the unrelated node process.
In an AI-agent environment the same misjudgment prints Dev server already running at http://localhost:4321/ (pid <tid>) and exits 0, so the agent believes a server is up when none is.
This is a follow-up to #17656 and #17744. The own-pid guard and the command check fixed the case where the new astro dev reuses the old pid. They do not cover an old pid that is now a thread of a different process.
What's the expected result?
A lock whose pid is not a running Astro process is stale, as when the pid is dead. Concretely, when ps succeeds but does not list the pid, isLockFileProcessAlive should return false. The lock is then removed, astro dev starts, and --force / stop never signal it.
The fix we are running as a patch on 7.3.8:
const processInfo = (await find('pid', data.pid, { logLevel: 'error' })).find(
({ pid }) => pid === data.pid,
);
- return processInfo?.cmd === undefined || isAstroCommand(processInfo.cmd);
+ // kill(pid, 0) also accepts a thread id, which ps does not list: not a running server.
+ if (processInfo === undefined) {
+ return false;
+ }
+ return processInfo.cmd === undefined || isAstroCommand(processInfo.cmd);It keeps the conservative true when ps itself fails (the catch). With it, the reproduction below starts the dev server, and the unrelated process is left alone. An alternative on Linux is to read /proc/<pid>/status and require Tgid == pid before trusting the pid.
Link to Minimal Reproducible Example
There is no StackBlitz link, because it needs real Linux thread ids, which a WebContainer does not have. This runs in any Astro project on Linux (for example npm create astro@latest -- --template minimal). Run it in a plain terminal; an AI-agent environment takes the agent-mode path described above.
node -e 'setInterval(() => {}, 1000)' & # an unrelated long-running process
HELPER=$!; sleep 1
TID=$(ls /proc/$HELPER/task | grep -vx "$HELPER" | head -1) # one of its threads
kill -0 "$TID" && echo "kill -0 accepts the thread id"
ps -p "$TID" >/dev/null || echo "ps does not list it"
mkdir -p .astro
printf '{"pid": %s, "port": 4321, "url": "http://localhost:4321/", "background": false, "startedAt": "2026-01-01T00:00:00.000Z"}' "$TID" > .astro/dev.json
npx astro dev # "Another astro dev server is already running ... PID: <TID>", exit 1
npx astro dev --force # SIGTERMs <TID>: the HELPER process dies
kill -0 "$HELPER" 2>/dev/null || echo "the unrelated process was killed"Verified on 7.3.8, and on 7.3.5 where we first hit it. The same check is unchanged in 7.4.0-beta.1 and on main.
Participation
- I am willing to submit a pull request for this issue.
Activity
astro-factory commentedon Oct 12, 2026
Triage complete
triage: fix pendingmain(Astro 7.3.8).packages/astro/test/units/dev/lockfile.test.ts.astro/dev.jsonworks around it. The worst part is thatstop/--forcecan kill an unrelated process.isLockFileProcessAliveinpackages/astro/src/core/dev/lockfile.tsdecides whether the lock file's pid is a running server. On Linux,process.kill(tid, 0)succeeds for a thread id, butps -p <tid>doesn't list it. find-proc then returns[], and the existing "command can't be inspected, trust the pid" fallback (processInfo?.cmd === undefined) treats the lock as a live server. Every path that starts, stops or replaces the server (astro dev,--force,stop, background, agent mode, preview) goes throughcheckExistingServer. So the dev server refuses to start, andstop/--forcesend SIGTERM/SIGKILL to the thread id, which kills the process that owns the thread.I found a potential fix for this issue. It still needs human review. The fix adds a Linux-only check: read
/proc/<pid>/statusand treat the lock as stale whenTgid !== pid(the alternative the reporter mentioned). I didn't use the reporter's main patch (processInfo === undefined → false). find-proc also returns[]whenpsisn't installed (common in slim Docker images), so that patch would mark a server that really is running as stale. The existing PID-only fallback for that case, and its unit test, are kept.Try this fix
You can test this fix right now without waiting for a release:
# astro npm i https://pkg.pr.new/astro@9bbf471If this fixes your issue, please leave a comment letting us know (for example
"confirmed, this fixes it"). A pull request will then be opened to get it merged.
Full Triage Report
Issue summary
astro devtreats a.astro/dev.jsonpid that is now a Linux thread id as a running Astro server:process.kill(pid, 0)succeeds, because thread ids are accepted.find('pid', pid)(find-proc →ps -p <pid>) returns nothing.processInfo?.cmd === undefined || isAstroCommand(...)therefore returnstrue.Real-world trigger: after a container restart, the lock file survives on a bind mount and pids restart from 1. The recorded pid is often a thread of another process (the reporter saw a turbo thread, and a thread of the container init).
Consequences:
astro devexits with "Another astro dev server is already running".astro dev --force/astro dev stopSIGTERM/SIGKILL the thread id, which kills the whole process that owns it (potentially PID 1 in a container).This follows #17656 / #17671 and #17744.
Environment
Linux x86_64, Node v22.23.2, Astro 7.3.8 from monorepo
main,examples/minimalcopy. Reporter: Astro 7.3.8/7.3.5, Node 24.8.0, Linux, bun. The bug isn't specific to bun.Steps to reproduce
node -e 'setInterval(() => {}, 1000)' &→ HELPERTID=$(ls /proc/$HELPER/task | grep -vx $HELPER | head -1)kill -0 $TIDsucceeds, andps -p $TIDlists nothing.astro/dev.jsonwith"pid": TIDastro dev, thenastro dev stop/astro dev --forceResult (before fix)
astro dev --forcealso killed the helper process. Baseline: with a dead pid (999999), the lock is treated as stale and the server starts normally.Root cause
packages/astro/src/core/dev/lockfile.ts,isLockFileProcessAlive:[]on anypserror during a pid lookup.ps -p <tid>exits 1, so the result is[].psbinary also gives[]. I checked both:processInfoisundefined, so the function returnstrue, andcheckExistingServerkeeps the lock. Every path that relies on it trusts the result:cli/dev/index.ts(refuse to start /killDevServer),cli/server.ts(stopExistingServer, agent "already running"), andcli/preview/index.ts.The
[]→truefallback is deliberate. The JSDoc says "If the command cannot be inspected, keep the existing PID-only behavior", and the unit test "keeps the PID-only result when the command cannot be inspected" covers it. But that fallback was meant for a missing or failingps, not for thread ids.Verification: bug (high confidence)
#17671 was written for exactly this scenario: "After a Docker container restart the PID is quickly reused by an unrelated process while the lock file persists on the volume mount". The thread-id variant was never considered: nothing in the code, tests or commits mentions it. Killing unrelated processes from
stop/--forceis clearly not intended.Fix
isLinuxThreadId(pid)(Linux only). It reads/proc/<pid>/statusand returns true whenTgid !== pid. If/proccan't be read, or on other platforms, it returns false.isLockFileProcessAlivereturnsfalsefor thread ids right after thekill(pid, 0)check.[]→truefallback for a missingpsis kept.Unit test:
returns false when the PID is a thread of another process(Linux only) inpackages/astro/test/units/dev/lockfile.test.ts. It takes a real thread id from a spawned child and assertsisLockFileProcessAlive({ pid: tid }, async () => []) === false. It fails without the fix and passes with it. All 39 tests in that file pass.Changeset:
.changeset/thread-pid-lockfile.md(astro: patch).End-to-end after the fix:
astro devstarts.astro dev stopprints "No dev server is running."astro dev --forcestarts the server.Lint, format and typecheck are clean.
Alternatives considered
processInfo === undefined → false): it's simpler, but it would mark a live server as stale on systems withoutps, because find-proc returns[]there too. It would also need the existing fallback test changed.kill(pid, 0)doesn't accept thread ids there in the same way.This report was made by an LLM. The analysis may be wrong, and the potential fix might not work, but is intended as a starting point for exploring the issue.
fix(astro): do not mistake Linux thread ID for running dev server (wi…