Commit Graph

9 Commits

Author SHA1 Message Date
xarmian 0ecec82357 fix(cli,server): a rate-limited health probe no longer starts a second server, and a start that loses the port touches nothing (TASK-3620 PR A) (#2106)
* fix(cli): a server that answers at all holds the port, so a 429 on the health probe no longer spawns a second server (TASK-3620)

* fix(server): pad server start binds before it opens the database, so a start that loses the port touches nothing (TASK-3620)

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ

* fix(cli): the liveness probe asks whether the port is held (a TCP connect), so a server still starting is not spawned over (TASK-3620, Codex r1)

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ

* fix(cli): separate "is the port held" from "is it serving"; a server still starting is waited for, never spawned over (TASK-3620, Codex r2)

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ

* fix(cli): the auto-start wait waits for a child still migrating, as EnsureServer does (TASK-3620, Codex r3)

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ
2026-10-12 11:28:46 -04:00
xarmian d4ec587de9 fix(cli): a CLI call during an install-refresh bounce waits for the refresh instead of auto-starting a server over it (BUG-3215) (#1544)
install-refresh.sh stops the server, installs, and restarts it with the
captured argv. Any pad CLI call in between found nothing answering and
auto-started a server of its own: the caller's binary, cwd and default
flags. That server held the port against the refresh's restart (day 79:
127.0.0.1:7777 went dark and the script exited 1).

- The script writes <DataDir>/refresh.lock {pid, port, deadline} by rename
  before the stop and removes it in its EXIT trap, only while it is still
  its own. The deadline is 10s + PAD_PROBE_TIMEOUT (30s by default).
- EnsureServer defers to a live marker for its own port (writer pid alive
  AND deadline not passed): it polls health and never spawns. If the
  writer is still alive past the deadline it errors naming the pid. A dead
  writer, a passed deadline, another port or an unparseable file means
  the marker is ignored.
- The script names a holder that took the port before the restart spawns
  (and does not spawn it), and refuses success when the listener after
  the probe is not the restart it spawned. A health probe answers for
  whoever holds the address.
- The script-test dependency guard now reads every install_refresh*_test.go.

Claude-Session: https://claude.ai/code/session_01A3rXV7GDLAhBe9mL4V6z2m
2026-09-25 09:55:48 -04:00
xarmian 4618876e3e fix(cli): pad server stop signals only a process it can prove is ours (BUG-2969) (#1299)
fix(cli): `pad server stop` signals only a process it can prove is ours (BUG-2969)

Measured on the merged binary before this change: a `sleep 600` whose pid had
been written into the PID file was SIGTERMed, and stop printed "Server stopped."
No pad server was running anywhere near that config.

Three things had to be true at once for that. os.FindProcess succeeds for ANY
pid on Unix. Nothing asked whether the pid belonged to a pad server. And the
confirmation loop polled the PORT — which is unhealthy from the first poll when
nothing was ever serving, so the success check was satisfied by the failure
case.

Liveness is the wrong question, and this is the trap the obvious fix falls into:
the stranger WAS alive. The question is whether the pid is OUR server.

## The discriminator

Unix takes an advisory flock on the PID file, held for the server's lifetime.
`stop` probes it non-blockingly: acquiring it proves nobody holds the file, so
the record is stale whatever the pid now names; failing to acquire proves a live
pad server holds THIS file. One implementation for Linux and macOS, no new
dependency, and the same primitive session_lock_unix.go has used since
TASK-2767.

Windows has no flock in that pattern, so it compares the process creation time
from GetProcessTimes against the one recorded at start — the attribute that
survives pid reuse, since a reused pid belongs to a process that started later.

The lead first ruled start-time comparison on every platform; I objected with
the cost (three implementations — /proc, a macOS sysctl promoting x/sys to a
direct dependency, and GetProcessTimes) and the ruling changed to this hybrid.
The cost table is on the item so the next reader sees why the shape moved.

The PID file gains a fingerprint on both platforms — pid, start time, executable
path — as JSON, with the legacy bare-integer form still parsed. A legacy record
carries no proof, which reads as UNPROVABLE, and unprovable means nothing is
signalled.

## Three races, each found by codex and each the same shape

1. Reading the record and checking ownership were separate steps, so a successor
   could claim the file between them: the lock then reported "held" — truthfully,
   about the successor — while the pid handed back was the predecessor's.
   pidFileOwner now returns the record it read from the descriptor it probed.
2. Removing the PID file after a successful stop could delete a fast successor's
   live record. It no longer removes at all there: the server removes its own on
   the way down, and a file left by a crash is handled by the next stop.
3. Removing a STALE file after the probe released the lock had the same window.
   The removal now happens inside the ownership check, while the lock is held —
   the only moment at which no replacement can have claimed the path. A claim
   arriving during that instant retries for half a second rather than losing its
   claim for the life of the process.

Windows deliberately does NOT delete a stale file: with no atomic primitive, a
check-then-remove would race a successor, and a stale file that the next start
overwrites is recoverable where a wrongly deleted record is not.

## Verified

Negative control, and it is the literal one: with the ownership check bypassed,
`go test` reports `signal: terminated` — the test binary is SIGTERMed by the
code under test, because the stale record names the test process itself.

Live, in throwaway HOMEs: a stale record naming a live `sleep` is refused and the
sleep survives (it was killed before this change); a stale record with a HEALTHY
port answering is still refused, nothing signalled, and both the stranger and the
real server survive; a server stopped through its own held record stops, and its
file is gone.

The CI smoke on windows-latest now stops the server with `pad server stop`
instead of Stop-Process, because that is the only place the Windows ownership
check runs — a smoke that killed the process directly would leave the
GetProcessTimes path unexercised on every platform.

make lint, make test green; codex CLEAN in round 4.

Claude-Session: https://claude.ai/code/session_01HeChkgZVYb3NTgTcckF5KR
2026-09-08 19:33:15 -04:00
xarmian 5ec17a7c92 fix(cli): pad server stop stops the server that is running, or says it is (BUG-2965) (#1298)
fix(cli): `pad server stop` stops the server that is running, or says it is (BUG-2965)

`StopServer` read the PID file and, on any read error, answered "server not
running (no PID file)" — without asking whether anything was listening. The file
was written in exactly one place, EnsureServer's auto-start branch, so a server
started any other way held the port with no file to find: a service unit, a
human running `pad server start`, the seats' refresh recipe relaunching with the
killed process's argv. A stop command that says "not running" about a running
process leaves the caller believing they stopped something, and the next thing
they do rests on that belief.

Two halves, per the item's property and corollary:

  - A missing PID file now asks the port. Only an unhealthy address earns "not
    running"; a healthy one earns a message naming the address, the missing
    file, and what to do instead. Deliberately NOT "find the listener and kill
    it" — resolving a pid from a port is platform-specific, and the process
    holding it may not be ours. A stop that kills by port can kill a stranger.
  - `pad server start` claims the PID file itself, so the file exists for every
    start path rather than only the auto-started one.

The second half took four codex rounds to get right, and each round found the
previous shape reintroducing the defect it was fixing:

  1. Write-then-defer-remove let a duplicate start overwrite a running server's
     entry and then delete it on the way out, leaving a healthy server
     unaddressable.
  2. Refusing to replace a live pid fixed that and opened its mirror: the start
     that LOST the port could still own the file, so the winner was unaddressable.
     The fix is ordering, not arbitration — BIND FIRST, then claim, so the file
     always names the process that owns the address. internal/server grows
     Listen and Serve for that; ListenAndServe is now the two together.
  3. With the bind first, EnsureServer's parent-side write became the stale
     mechanism (it records a child that may never bind) and the live-pid refusal
     became actively wrong (no live process can be serving an address we just
     bound). Both removed, along with processIsAlive, whose only remaining
     callers were its own tests.
  4. Cleanup is a read-then-remove, so running it AFTER the listener closes let
     a successor bind and claim between the two steps and lose its file to us.
     It now runs before the listener closes, while nothing else can legitimately
     own the file. The cost is a drain-window where a healthy server has no PID
     file and `stop` says so — a true message in place of a silent wrong one.

Verified live against the built binary, in a throwaway HOME, in both shapes:
start writes the file naming the serving process; a second start against the
held port fails at bind and leaves the first server's file intact; stop then
stops it and removes the file; a further stop reports "not running". The first
live run also caught a flaw in my own method — `stop` reads the config's port,
so the probe answered about 127.0.0.1:7777 (this box's dev server) until it was
re-run with PAD_PORT set. Re-checked after the restructure.

Mutants: the health check removed, the health branch still answering "not
running", an empty PID file, a cleanup that does not remove, and a cleanup that
removes a successor's file are each killed by a named test. The call site itself
is wiring a unit test cannot vouch for (CONVE-19) — that is what the live runs
cover, and the Listen/Serve split is pinned in internal/server.

make lint, make test green.

Claude-Session: https://claude.ai/code/session_01HeChkgZVYb3NTgTcckF5KR
2026-09-08 18:26:32 -04:00
xarmian 7cda0d7896 feat: rebrand to Perpetual Software + new tagline (IDEA-832) (#273)
Migrates from xarmian/pad to PerpetualSoftware/pad across the entire
repo and updates the product subtitle to "Collaborate with your AI
agents".

Go module rename
- go.mod: github.com/xarmian/pad → github.com/PerpetualSoftware/pad
- All Go imports updated across cmd/pad, internal/{cli,server,store,
  models,collections,items,events,metrics,webhooks} (~130 files)
- Test fixtures with the literal repo slug ("xarmian/pad" in JSON
  shapes, SSH/HTTPS git URL strings, workspace_context fixtures)
  also updated, including the secondary repo entry
  (xarmian/pad-web → PerpetualSoftware/pad-web — pad-web was also
  moved to the org per branch context)

Docs / config
- README badges, install instructions, brew tap, Docker image, source
  build path, sponsor link (sponsor link kept as personal @xarmian)
- Subtitle: "Project management for developers and AI agents." →
  "Collaborate with your AI agents." (README, manifests, web layout
  meta, .goreleaser homebrew description)
- CONTRIBUTING.md, SECURITY.md, skills/INSTALL.md
- .goreleaser.yaml: homebrew_casks owner, GHCR image, release github
  owner, cosign cert-identity regex, comments
- .github/workflows/release.yml: tap/release comments
- deploy/k8s/deployment.yaml: container image
- docs/deployment.md: clone URL
- web/static/{site.webmanifest,manifest.json}: description
- web/src/routes/+layout.svelte: meta description + og:description

Brew tap path is PerpetualSoftware/tap/pad (CamelCase, matches
GitHub user case). GHCR image is ghcr.io/perpetualsoftware/pad
(lowercased per GHCR's URL normalization). CODEOWNERS @xarmian and
FUNDING.yml github: xarmian intentionally retained — those are the
personal maintainer / sponsor account, separate from the org repo.

Verification: go build ./..., go test ./... (all pkgs pass), web
build, and make install all clean (TASK-844, TASK-845).
2026-04-28 12:26:39 -04:00
xarmian f5649b912e refactor(cli): group first-release commands for TASK-127 (#45) 2026-04-02 15:28:16 -04:00
xarmian 5db077a2a3 refactor(cli): limit local server autostart to local mode for TASK-116 (#35) 2026-04-01 21:56:18 -04:00
xarmian 123a7aec98 feat(cli): add client configure flow for TASK-115 (#34) 2026-04-01 21:32:55 -04:00
xarmian 81579847c6 Initial release
Pad — project management for developers and AI agents.
Single Go binary with embedded SvelteKit web UI, SQLite storage,
CLI, and Claude Code /pad skill integration.

https://getpad.dev
2026-03-26 01:52:36 +00:00