Skip to content

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

Open

Description

@alessionobile

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

  1. process.kill(pid, 0) succeeds, then
  2. find("pid", pid) (find-proc, i.e. ps -p <pid>) is asked for the command line, and
  3. return 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

  1. astro-factory commented on Oct 12, 2026

    @astro-factory
    Contributor

    Triage complete

    • Prepare the workspace: ready
    • Install dependencies: complete
    • Build the project: complete
    • Reproduce the issue: confirmed
    • Diagnose the cause: high confidence
    • Verify the diagnosis: bug
    • Attempt a fix: verified
    • Publish the result: set triage: fix pending

    • Reproduced: Yes, on Linux, using the reporter's script on current main (Astro 7.3.8).
    • Exploration: Yes — View branch
    • Unit Test: Yes — packages/astro/test/units/dev/lockfile.test.ts
    • Priority: P3: minor bug. It only happens on Linux when a stale lock file survives a restart and its pid now belongs to a thread, which mostly means containers with bind mounts. Deleting .astro/dev.json works around it. The worst part is that stop / --force can kill an unrelated process.

    isLockFileProcessAlive in packages/astro/src/core/dev/lockfile.ts decides whether the lock file's pid is a running server. On Linux, process.kill(tid, 0) succeeds for a thread id, but ps -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 through checkExistingServer. So the dev server refuses to start, and stop/--force send 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>/status and treat the lock as stale when Tgid !== pid (the alternative the reporter mentioned). I didn't use the reporter's main patch (processInfo === undefined → false). find-proc also returns [] when ps isn'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@9bbf471

    If 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 dev treats a .astro/dev.json pid that is now a Linux thread id as a running Astro server:

    1. process.kill(pid, 0) succeeds, because thread ids are accepted.
    2. find('pid', pid) (find-proc → ps -p <pid>) returns nothing.
    3. processInfo?.cmd === undefined || isAstroCommand(...) therefore returns true.

    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 dev exits with "Another astro dev server is already running".
    • astro dev --force / astro dev stop SIGTERM/SIGKILL the thread id, which kills the whole process that owns it (potentially PID 1 in a container).
    • Agent mode reports a server running when there isn't one.

    This follows #17656 / #17671 and #17744.

    Environment

    Linux x86_64, Node v22.23.2, Astro 7.3.8 from monorepo main, examples/minimal copy. Reporter: Astro 7.3.8/7.3.5, Node 24.8.0, Linux, bun. The bug isn't specific to bun.

    Steps to reproduce

    1. node -e 'setInterval(() => {}, 1000)' & → HELPER
    2. TID=$(ls /proc/$HELPER/task | grep -vx $HELPER | head -1)
    3. kill -0 $TID succeeds, and ps -p $TID lists nothing
    4. Write .astro/dev.json with "pid": TID
    5. Run astro dev, then astro dev stop / astro dev --force

    Result (before fix)

    Another astro dev server is already running.
    
      URL:  http://localhost:4321/
      PID:  1883
    
    Run `astro dev stop` to stop it, or use `astro dev --force` to replace it.
    exit=1
    === astro dev stop ===
    Stopped dev server (pid 1883).
    the unrelated process was killed
    

    astro dev --force also 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:

    • find-proc 0.2.0 on linux/darwin resolves [] on any ps error during a pid lookup. ps -p <tid> exits 1, so the result is [].
    • A missing ps binary also gives []. I checked both:
      tid status: Tgid: 2126 | Pid: 2127
      tid []
      helper with no ps on PATH []
      
    • processInfo is undefined, so the function returns true, and checkExistingServer keeps 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"), and cli/preview/index.ts.

    The [] → true fallback 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 failing ps, 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/--force is clearly not intended.

    Fix

    • New helper isLinuxThreadId(pid) (Linux only). It reads /proc/<pid>/status and returns true when Tgid !== pid. If /proc can't be read, or on other platforms, it returns false.
    • isLockFileProcessAlive returns false for thread ids right after the kill(pid, 0) check.
    • The [] → true fallback for a missing ps is kept.
    +function isLinuxThreadId(pid: number): boolean {
    +	if (process.platform !== 'linux') {
    +		return false;
    +	}
    +	try {
    +		const tgid = /^Tgid:\s*(\d+)$/m.exec(readFileSync(`/proc/${pid}/status`, 'utf-8'))?.[1];
    +		return tgid !== undefined && Number(tgid) !== pid;
    +	} catch {
    +		return false;
    +	}
    +}
    ...
    +	// On Linux, `kill(tid, 0)` succeeds for a thread id, but `ps` does not list it, so the
    +	// command lookup below cannot tell it apart from an uninspectable process. After a
    +	// container restart the recorded PID is often a thread of an unrelated process. (#18343)
    +	if (isLinuxThreadId(data.pid)) {
    +		return false;
    +	}

    Unit test: returns false when the PID is a thread of another process (Linux only) in packages/astro/test/units/dev/lockfile.test.ts. It takes a real thread id from a spawned child and asserts isLockFileProcessAlive({ 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 dev starts.
    • astro dev stop prints "No dev server is running."
    • astro dev --force starts the server.
    • The helper process survives in all three cases.

    Lint, format and typecheck are clean.

    Alternatives considered

    • Reporter's patch (processInfo === undefined → false): it's simpler, but it would mark a live server as stale on systems without ps, because find-proc returns [] there too. It would also need the existing fallback test changed.
    • macOS and Windows aren't affected, because 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.

  2. added
    - P3: minor bugAn edge case that only affects very specific usage (priority)
    pkg: astroRelated to the core `astro` package (scope)
    triage: fix pendingReporter needs to verify the triage bot fix works
    and removed on Oct 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

- P3: minor bugAn edge case that only affects very specific usage (priority)pkg: astroRelated to the core `astro` package (scope)triage: fix pendingReporter needs to verify the triage bot fix works

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions