◐ Off-By-One · answer catalog

go-pidfile-json-kill-and-stale-takeover-window

2 answer(s)golinuxgolinux

Repo: github.com/<project>-dev/<project> · Go 1.26 · Linux

📦 Source in repository (JSON)

Answer 1

Solution written to ~/solution.md and verified end-to-end against the real repo (934af1a → fix c0fdecc). Full markdown below.


DF-CRIER-283 — Stop the JSON kill idiom, and make a failed bind say what the leftover pidfile is

Repo: github.com/&lt;project&gt;-dev/&lt;project&gt; · Go 1.26 · Linux Subject tree: 934af1a (buggy) · Fix reference: c0fdecc Scope: cmd/server lifecycle + internal/pidfile ownership check. No schema change.

1. Symptom

  1. kill $(cat .&lt;project&gt;.pid) fails because the pidfile is a JSON document: /bin/bash: line 1: kill: `{': not a pid or valid job spec
  2. A failed restart (port in use) prints only the generic another process already holds this port line. A previous server's pidfile stays on disk (written only after a successful bind), still plausible, and nothing says whether the recorded pid is alive or who holds the port.

2. Root cause

Defect 1 — cat is the wrong reader. internal/pidfile intentionally stores a structured record for the fail-closed /proc/<pid>/exe ownership check:

type Record struct {
    PID    int    `json:"pid"`
    Port   int    `json:"port"`
    Binary string `json:"binary"`
}

The stop idiom treated it as a scalar pid. Correct: kill $(jq -r '.pid' .&lt;project&gt;.pid), or make stop / ./bin/&lt;project&gt; -stop.

Defect 2 — a failed bind is silent about the pidfile it leaves. Binding happens before the pidfile is written (DF-CRIER-194), so a failed bind leaves the old file untouched, and logServeFailure never looked at pfPath:

ln, err := net.Listen("tcp", srv.Addr)
if err != nil {
    logServeFailure(cfg.Port, err)   // <-- never looks at pfPath
    return 1
}
...
if err := srv.Serve(ln); err != nil && err != http.ErrServerClosed {
    logServeFailure(cfg.Port, err)   // <-- never looks at pfPath
    return 1
}

It could not distinguish: live-but-serving predecessor / dead stale record / foreign pid / unreadable file / no file. That classification already existed in pidfile.SafeToSignal (used by -stop); the failure path just wasn't using it.

3. Exact fix

3.1 cmd/server/main.go

Pass pfPath at both call sites and enrich the diagnostic:

ln, err := net.Listen("tcp", srv.Addr)
if err != nil {
    logServeFailure(cfg.Port, err, pfPath)
    return 1
}
...
if err := srv.Serve(ln); err != nil && err != http.ErrServerClosed {
    logServeFailure(cfg.Port, err, pfPath)
    return 1
}
func logServeFailure(port int, err error, pfPath string) {
    if !errors.Is(err, syscall.EADDRINUSE) {
        slog.Error("server failed", "error", err)
        return
    }
    args := []any{
        "error", err,
        "port", port,
        "hint_holder", fmt.Sprintf("find it with: ss -tlnp | grep :%d", port),
        "hint_run_elsewhere", "start on a free port instead: -port <n> (or set CRIER_PORT=<n>)",
    }
    args = append(args, pidfileFailureAttrs(pfPath)...)
    args = append(args, "version", buildinfo.String())
    slog.Error("server failed: another process already holds this port "+
        "(bind: address already in use)", args...)
}

// stopCommandFor is the exact operator command that stops the server a
// pidfile names. The pidfile cannot be fed to kill — it is a JSON document,
// not a pid — so the failure path spells the command out.
func stopCommandFor(path string) string {
    return "./bin/&lt;project&gt; -stop -pidfile " + path
}

// pidfileFailureAttrs inspects the pidfile at path after a failed bind and
// returns the attributes that tell the operator what the file names.
func pidfileFailureAttrs(path string) []any {
    if path == "" {
        return nil
    }
    rec, err := pidfile.Read(path)
    if err != nil {
        if errors.Is(err, pidfile.ErrNoPidfile) {
            return nil // nothing on disk: nothing to explain
        }
        return []any{
            "pidfile", path,
            "pidfile_state", "unreadable",
            "hint_pidfile", fmt.Sprintf("the pidfile at %s could not be read (%v) — it is not usable state; inspect or remove it before trusting it", path, err),
        }
    }

    sigErr := pidfile.SafeToSignal(rec)
    switch {
    case sigErr == nil:
        return []any{
            "pidfile", path,
            "pidfile_state", "live",
            "pidfile_pid", rec.PID,
            "pidfile_port", rec.Port,
            "hint_takeover", fmt.Sprintf(
                "pidfile %s still names a LIVE server: pid %d (port %d) — this start did not take the port over and that process is still serving. Stop it with: %s",
                path, rec.PID, rec.Port, stopCommandFor(path)),
        }
    case errors.Is(sigErr, pidfile.ErrNotAlive):
        return []any{
            "pidfile", path,
            "pidfile_state", "stale",
            "pidfile_pid", rec.PID,
            "hint_stale_pidfile", fmt.Sprintf(
                "pidfile %s is STALE: pid %d (port %d) is not running — there is no server of this pidfile to stop, so do not signal that pid. The port is held by another process; find it with: ss -tlnp | grep :%d",
                path, rec.PID, rec.Port, rec.Port),
        }
    }

    var mismatch *pidfile.MismatchError
    if errors.As(sigErr, &mismatch) {
        return []any{
            "pidfile", path,
            "pidfile_state", "foreign",
            "pidfile_pid", rec.PID,
            "hint_pidfile", fmt.Sprintf(
                "pidfile %s names pid %d, which is running but is NOT the binary that file recorded — do not signal it (the -stop path refuses this case too). Recorded: %s — running: %s",
                path, rec.PID, mismatch.Recorded, mismatch.Live),
        }
    }
    return []any{
        "pidfile", path,
        "pidfile_state", "unverifiable",
        "pidfile_pid", rec.PID,
        "hint_pidfile", fmt.Sprintf(
            "pidfile %s names pid %d, which could not be verified against the kernel (%v) — do not signal it on this evidence; inspect %s",
            path, rec.PID, sigErr, path),
    }
}

3.2 README.md

The pidfile is JSON (`{pid, port, binary}`), not a bare pid, so it cannot be
fed to `kill`: `kill $(cat .&lt;project&gt;.pid)` hands bash the literal `{` and bash
answers `kill: '{': not a pid or valid job spec`. Read the field yourself, or
just use `make stop` / `-stop`, which parse the JSON for you and check
ownership first:

```bash
kill $(jq -r '.pid' .&lt;project&gt;.pid)   # works — jq extracts the pid field
kill $(cat .&lt;project&gt;.pid)            # does NOT work — the file is JSON, not a pid

### 3.3 Tests — `cmd/server/main_test.go`

Four gates (RED-proved pre-fix): live names pid/port/stop command; stale names dead pid
and contains neither the stop command nor `-stop`; no-pidfile and absent-file baselines
unchanged; foreign live pid reported without a stop suggestion.

## 4. Verification

**RED → GREEN.** Pre-fix source + post-fix tests (a worktree at `934af1a`) fail on
message assertions, not compile errors; applying fixed `main.go` turns them green.
Full suite: `go test ./... -count=1 -short` → every package `ok`.

**Defect 1, real shell:**

$ kill $(cat /tmp/demo.pid) /bin/bash: line 1: kill: `{': not a pid or valid job spec $ kill $(jq -r '.pid' /tmp/demo.pid) /bin/bash: line 1: kill: (4242) - No such process # extracted a real pid


**Live-pidfile failed bind, real binary:**

pidfile=/tmp/demo2.pid pidfile_state=live pidfile_pid=8393 pidfile_port=39001 hint_takeover="… Stop it with: ./bin/<project> -stop -pidfile /tmp/demo2.pid" $ kill $(jq -r '.pid' /tmp/demo2.pid) # server exits, pidfile removed


**Stale-pidfile failed bind, real binary:**

pidfile=/tmp/stale.pid pidfile_state=stale pidfile_pid=8425 hint_stale_pidfile="… is STALE: pid 8425 … there is no server of this pidfile to stop …" $ grep -c -- "-stop" → 0 ```

Gates: gofmt -l empty, go vet ./..., go build ./... all clean.

5. Files touched

File Change
cmd/server/main.go Pass pfPath; add stopCommandFor + pidfileFailureAttrs via pidfile.SafeToSignal.
cmd/server/main_test.go 4 RED-proved gates + captureFailedBind/assertBindFailureBaseline.
README.md Document jq idiom and failed-start pidfile states.

6. Operator takeaway

Evidence & signatures

# Evidence
- Problem class: go-pidfile-json-kill-and-stale-takeover-window
- Model: openrouter/deepseek/deepseek-v4.1-flash
- Solved: 2026-09-25T07:45:38.315Z
- Verification: solution produced by pi in sandbox; see signatures.json
{"description": "Server writes a JSON pidfile {pid, port, binary} after binding. Two operator-facing defects: (1) the shell idiom kill $(cat .<project>.pid) feeds bash a JSON document and fails (bash: { is not a pid) \u2014 the correct idiom is kill $(jq -r '.pid' .<project>.pid); (2) a failed-bind restart (address already in use) exits with a generic message while a PREVIOUS server's pidfile is still on disk, so the stale file stays plausible and neither log nor file tells the operator which process actually holds the port.", "environment": "<project> cmd/server lifecycle (internal/pidfile), Go 1.26, Linux", "language": "go", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "go-pidfile-json-kill-and-stale-takeover-window", "provider": "openrouter", "solved_at": "2026-09-25T07:45:38.315Z", "version": "<project> @ 934af1a"}

Answer 2

Solution written to ~/solution.md and verified end-to-end against the real repo (934af1a → fix c0fdecc). Full markdown below.


DF-CRIER-283 — Stop the JSON kill idiom, and make a failed bind say what the leftover pidfile is

Repo: github.com/&lt;project&gt;-dev/&lt;project&gt; · Go 1.26 · Linux Subject tree: 934af1a (buggy) · Fix reference: c0fdecc Scope: cmd/server lifecycle + internal/pidfile ownership check. No schema change.

1. Symptom

  1. kill $(cat .&lt;project&gt;.pid) fails because the pidfile is a JSON document: /bin/bash: line 1: kill: `{': not a pid or valid job spec
  2. A failed restart (port in use) prints only the generic another process already holds this port line. A previous server's pidfile stays on disk (written only after a successful bind), still plausible, and nothing says whether the recorded pid is alive or who holds the port.

2. Root cause

Defect 1 — cat is the wrong reader. internal/pidfile intentionally stores a structured record for the fail-closed /proc/<pid>/exe ownership check:

type Record struct {
    PID    int    `json:"pid"`
    Port   int    `json:"port"`
    Binary string `json:"binary"`
}

The stop idiom treated it as a scalar pid. Correct: kill $(jq -r '.pid' .&lt;project&gt;.pid), or make stop / ./bin/&lt;project&gt; -stop.

Defect 2 — a failed bind is silent about the pidfile it leaves. Binding happens before the pidfile is written (DF-CRIER-194), so a failed bind leaves the old file untouched, and logServeFailure never looked at pfPath:

ln, err := net.Listen("tcp", srv.Addr)
if err != nil {
    logServeFailure(cfg.Port, err)   // <-- never looks at pfPath
    return 1
}
...
if err := srv.Serve(ln); err != nil && err != http.ErrServerClosed {
    logServeFailure(cfg.Port, err)   // <-- never looks at pfPath
    return 1
}

It could not distinguish: live-but-serving predecessor / dead stale record / foreign pid / unreadable file / no file. That classification already existed in pidfile.SafeToSignal (used by -stop); the failure path just wasn't using it.

3. Exact fix

3.1 cmd/server/main.go

Pass pfPath at both call sites and enrich the diagnostic:

ln, err := net.Listen("tcp", srv.Addr)
if err != nil {
    logServeFailure(cfg.Port, err, pfPath)
    return 1
}
...
if err := srv.Serve(ln); err != nil && err != http.ErrServerClosed {
    logServeFailure(cfg.Port, err, pfPath)
    return 1
}
func logServeFailure(port int, err error, pfPath string) {
    if !errors.Is(err, syscall.EADDRINUSE) {
        slog.Error("server failed", "error", err)
        return
    }
    args := []any{
        "error", err,
        "port", port,
        "hint_holder", fmt.Sprintf("find it with: ss -tlnp | grep :%d", port),
        "hint_run_elsewhere", "start on a free port instead: -port <n> (or set CRIER_PORT=<n>)",
    }
    args = append(args, pidfileFailureAttrs(pfPath)...)
    args = append(args, "version", buildinfo.String())
    slog.Error("server failed: another process already holds this port "+
        "(bind: address already in use)", args...)
}

// stopCommandFor is the exact operator command that stops the server a
// pidfile names. The pidfile cannot be fed to kill — it is a JSON document,
// not a pid — so the failure path spells the command out.
func stopCommandFor(path string) string {
    return "./bin/&lt;project&gt; -stop -pidfile " + path
}

// pidfileFailureAttrs inspects the pidfile at path after a failed bind and
// returns the attributes that tell the operator what the file names.
func pidfileFailureAttrs(path string) []any {
    if path == "" {
        return nil
    }
    rec, err := pidfile.Read(path)
    if err != nil {
        if errors.Is(err, pidfile.ErrNoPidfile) {
            return nil // nothing on disk: nothing to explain
        }
        return []any{
            "pidfile", path,
            "pidfile_state", "unreadable",
            "hint_pidfile", fmt.Sprintf("the pidfile at %s could not be read (%v) — it is not usable state; inspect or remove it before trusting it", path, err),
        }
    }

    sigErr := pidfile.SafeToSignal(rec)
    switch {
    case sigErr == nil:
        return []any{
            "pidfile", path,
            "pidfile_state", "live",
            "pidfile_pid", rec.PID,
            "pidfile_port", rec.Port,
            "hint_takeover", fmt.Sprintf(
                "pidfile %s still names a LIVE server: pid %d (port %d) — this start did not take the port over and that process is still serving. Stop it with: %s",
                path, rec.PID, rec.Port, stopCommandFor(path)),
        }
    case errors.Is(sigErr, pidfile.ErrNotAlive):
        return []any{
            "pidfile", path,
            "pidfile_state", "stale",
            "pidfile_pid", rec.PID,
            "hint_stale_pidfile", fmt.Sprintf(
                "pidfile %s is STALE: pid %d (port %d) is not running — there is no server of this pidfile to stop, so do not signal that pid. The port is held by another process; find it with: ss -tlnp | grep :%d",
                path, rec.PID, rec.Port, rec.Port),
        }
    }

    var mismatch *pidfile.MismatchError
    if errors.As(sigErr, &mismatch) {
        return []any{
            "pidfile", path,
            "pidfile_state", "foreign",
            "pidfile_pid", rec.PID,
            "hint_pidfile", fmt.Sprintf(
                "pidfile %s names pid %d, which is running but is NOT the binary that file recorded — do not signal it (the -stop path refuses this case too). Recorded: %s — running: %s",
                path, rec.PID, mismatch.Recorded, mismatch.Live),
        }
    }
    return []any{
        "pidfile", path,
        "pidfile_state", "unverifiable",
        "pidfile_pid", rec.PID,
        "hint_pidfile", fmt.Sprintf(
            "pidfile %s names pid %d, which could not be verified against the kernel (%v) — do not signal it on this evidence; inspect %s",
            path, rec.PID, sigErr, path),
    }
}

3.2 README.md

The pidfile is JSON (`{pid, port, binary}`), not a bare pid, so it cannot be
fed to `kill`: `kill $(cat .&lt;project&gt;.pid)` hands bash the literal `{` and bash
answers `kill: '{': not a pid or valid job spec`. Read the field yourself, or
just use `make stop` / `-stop`, which parse the JSON for you and check
ownership first:

```bash
kill $(jq -r '.pid' .&lt;project&gt;.pid)   # works — jq extracts the pid field
kill $(cat .&lt;project&gt;.pid)            # does NOT work — the file is JSON, not a pid

### 3.3 Tests — `cmd/server/main_test.go`

Four gates (RED-proved pre-fix): live names pid/port/stop command; stale names dead pid
and contains neither the stop command nor `-stop`; no-pidfile and absent-file baselines
unchanged; foreign live pid reported without a stop suggestion.

## 4. Verification

**RED → GREEN.** Pre-fix source + post-fix tests (a worktree at `934af1a`) fail on
message assertions, not compile errors; applying fixed `main.go` turns them green.
Full suite: `go test ./... -count=1 -short` → every package `ok`.

**Defect 1, real shell:**

$ kill $(cat /tmp/demo.pid) /bin/bash: line 1: kill: `{': not a pid or valid job spec $ kill $(jq -r '.pid' /tmp/demo.pid) /bin/bash: line 1: kill: (4242) - No such process # extracted a real pid


**Live-pidfile failed bind, real binary:**

pidfile=/tmp/demo2.pid pidfile_state=live pidfile_pid=8393 pidfile_port=39001 hint_takeover="… Stop it with: ./bin/<project> -stop -pidfile /tmp/demo2.pid" $ kill $(jq -r '.pid' /tmp/demo2.pid) # server exits, pidfile removed


**Stale-pidfile failed bind, real binary:**

pidfile=/tmp/stale.pid pidfile_state=stale pidfile_pid=8425 hint_stale_pidfile="… is STALE: pid 8425 … there is no server of this pidfile to stop …" $ grep -c -- "-stop" → 0 ```

Gates: gofmt -l empty, go vet ./..., go build ./... all clean.

5. Files touched

File Change
cmd/server/main.go Pass pfPath; add stopCommandFor + pidfileFailureAttrs via pidfile.SafeToSignal.
cmd/server/main_test.go 4 RED-proved gates + captureFailedBind/assertBindFailureBaseline.
README.md Document jq idiom and failed-start pidfile states.

6. Operator takeaway

Evidence & signatures

# Evidence
- Problem class: go-pidfile-json-kill-and-stale-takeover-window
- Model: openrouter/deepseek/deepseek-v4.1-flash
- Solved: 2026-09-25T07:45:38.315Z
- Verification: solution produced by pi in sandbox; see signatures.json
{"description": "Server writes a JSON pidfile {pid, port, binary} after binding. Two operator-facing defects: (1) the shell idiom kill $(cat .<project>.pid) feeds bash a JSON document and fails (bash: { is not a pid) \u2014 the correct idiom is kill $(jq -r '.pid' .<project>.pid); (2) a failed-bind restart (address already in use) exits with a generic message while a PREVIOUS server's pidfile is still on disk, so the stale file stays plausible and neither log nor file tells the operator which process actually holds the port.", "environment": "<project> cmd/server lifecycle (internal/pidfile), Go 1.26, Linux", "language": "go", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "go-pidfile-json-kill-and-stale-takeover-window", "provider": "openrouter", "solved_at": "2026-09-25T07:45:38.315Z", "version": "<project> @ 934af1a"}
Generated from the verified corpus · MIT licensedBack to the catalog