Grove 1.6.0: A daemon that tells the truth
Grove 1.6.0 stops reporting what it meant to do and starts reporting what happened: honest status and doctor, HTTP/2, HTTPS redirects, WebSocket passthrough, atomic state writes, orphan reaping — and Linux, in beta.
Grove 1.5.0 was about trusting less: privilege drops, a name-constrained CA, checksummed downloads, a secret-sync client that no longer let the server pick who could decrypt. When that shipped we did what you should do after a security release — we went looking for everything else. Not vulnerabilities this time. Just the ordinary ways a local dev tool can let you down.
The theme that came out of it surprised us. Grove wasn't lying, exactly. But over and over, it reported what it meant to do rather than what had happened. It said "started" when port 80 was held by Apache. It said "removed" when nothing had been removed. It said a PHP pool was healthy while every request to it failed.
1.6.0 fixes all of that, plus the things we found on the way. Here is the release, with the screen in front of you.
The bug that hid in a correct-looking place
Start with the one real bug, because it explains why we ended up doing the rest.
When a php-fpm master died — an OOM, a stray killall, a crash — Grove noticed on the next request and spawned a new one. Good. Then it put the new pool into its map, and the map handed back the old pool, which was dropped on the spot. Dropping a pool removes its socket file.
The old pool and the new pool have the same socket path.
So the new master came up, bound its socket, and a millisecond later Grove deleted it. The child was alive, so nothing ever respawned again. Every request for that PHP version answered 502 until you restarted the daemon.
Two reviews had looked at this code and called the respawn path correct. Both checked that a dead child is detected. Neither asked what happened to the one being replaced. The fix is one line — take the dead pool out of the map before spawning — and the test now runs the real respawn path against a stand-in php-fpm; it fails on the old code at exactly that assertion.
That was the moment the release got its theme. Grove had recovered from the crash and then, silently, un-recovered.
Status and doctor say what actually happened
Before 1.6.0, grove status printed ● dns from a hardcoded true. A failed bind was one line in daemon.log. If another server held port 80 you got a green light and nothing served.
Now every listener records how its bind went, and the OS is asked who has the port:
$ grove status
Grove 1.6.0
TLD .test
HTTP :80
HTTPS :443
DNS :53
Sites 14
● dns
○ http Address already in use (os error 48) — held by httpd (pid 412)
● https
● mail
grove doctor grew up the same way. It used to be an IPC round-trip, so when the daemon was down — the one time you really want a doctor — it said "not running" and stopped. It now runs its filesystem and resolver checks locally, tells you the daemon is the missing piece, and exits non-zero if anything is ✗, so you can gate a script on it:
$ grove doctor
✓ config loaded from /Users/you/Library/Application Support/Grove/config.toml
✓ root-ca present at /Users/you/Library/Application Support/Grove/certs/grove-ca.pem
! root-ca-scope constrained to .test but the configured TLD is .dev — sites will fail TLS until `sudo grove ca rotate`
✗ resolver /etc/resolver/dev missing — re-run `sudo grove install` to register the .dev resolver
✗ daemon not running (no socket at …/run/groved.sock) — `sudo grove install`, or `grove daemon` to run it in the foreground
The resolver check is new and does the one thing you actually care about: it asks the operating system to resolve a name under your TLD and expects loopback back. That is the check a VPN client breaks when it rewrites DNS order without touching a single file.
The proxy grows up
Grove is the web server for your sites, so this is where most of the day-to-day lives.
HTTP/2. The TLS listener offered only http/1.1 in ALPN. Every browser fell back to six connections per origin, and a Laravel page with forty Vite module requests loaded them in batches. Both listeners now negotiate h2:
$ curl -sI --http2 https://myapp.test/
HTTP/2 200
We will be honest about how that went. Enabling h2 was green in every unit test and returned 404 no site registered for host "" on the first real request. HTTP/2 has no Host header — the host travels as :authority — and the handler only read the header. The end-to-end smoke test caught it the same afternoon, which is why every change in this release was tried against a live daemon before we believed the suite.
Secured sites redirect. You ran grove secure myapp. Then you typed myapp.test into a browser, which defaults to http://, and Grove served it in plaintext. PHP saw HTTPS="", Laravel built http:// asset URLs, and you got mixed-content warnings against your own HTTPS Vite server. Now:
$ curl -sI http://myapp.test/admin?tab=2
HTTP/1.1 301 Moved Permanently
location: https://myapp.test/admin?tab=2
301 for GET and HEAD so browsers remember, 308 for everything else so a POST keeps its method and body.
WebSockets pass through. The listeners had accepted upgrades all along. Nothing pumped the bytes afterwards, so the browser got a 101 and a dead socket, and Vite HMR on a proxy site, Next and Nuxt dev servers, Reverb over wss:// — they all reconnect-looped. Upgrades now get their own connection to the upstream and a bidirectional copy until either side hangs up. Laravel users were spared before only because Grove starts Vite on its own port with its own certificate; everyone else wasn't.
Error pages that name the fix. Every error Grove generated itself was a bare text/plain line. When your Vite server wasn't running, the browser showed:
Grove: client error (Connect): tcp connect error: Connection refused (os error 61)
Now you get a page that says what happened and, when Grove knows, what to do:
502 Bad Gateway
Nothing is listening at http://127.0.0.1:5173
The dev server behind myapp.test isn't running.
grove dev start myapp # if Grove manages it
npm run dev # or start it yourself
An unknown host offers grove link. A missing PHP version offers grove php install. A stopped container says which one.
Static files behave. Four small things that were each a ten-minute detour:
The SPA fallback served index.html as 200 text/html for any missing path — so a stale hashed asset didn't 404, it produced "expected a JavaScript MIME type" in the console. The fallback now applies only to extension-less paths from clients that accept HTML. Safari refused to play <video> because Grove never answered 206; single Range requests work now, with Accept-Ranges advertised. Compressible assets are gzipped when the browser asks:
$ curl -sI -H 'Accept-Encoding: gzip' https://myapp.test/build/assets/app.js
content-encoding: gzip
content-length: 83
etag: "6a988352-1195-gz"
vary: Accept-Encoding
(That file was 4501 bytes uncompressed. The -gz on the ETag matters: a different representation needs a different validator, and the 304 path accepts either.) And mime_for finally knows about video, audio, fonts beyond woff, XML, PDF, AVIF, CSV, source maps and web manifests — sitemaps used to download instead of render.
PHP gets dev-sized defaults. Grove writes no php.ini, so PHP's compiled-in defaults applied: 8M uploads, 128M memory, 30-second scripts. A CSV import through the browser hit all three. The pool config now sets 512M for uploads, post size and memory, 300s for scripts, and a 600s request_terminate_timeout so a worker stuck in a loop is recycled instead of held forever — with sixteen workers, sixteen of those was a site that hung with no error at all. These are php_value, not php_admin_value, so your app's own ini_set() still wins.
State that survives a bad day
Every state file Grove owns — config.toml, php-builds.json, the service and snapshot indexes — was written in place with truncate-then-write, and read back with "if it doesn't parse, use defaults". Put those together and a crash mid-write turned a truncated php-builds.json into an empty registry, which Grove then saved over the file. Every PHP you had installed through Grove, forgotten, silently.
Writes are now atomic: temp file, fsync, rename. A file that exists but doesn't parse is moved aside as <name>.corrupt-<timestamp>, logged at error level, and reported by grove doctor until you deal with it. Your bytes are still there.
The daemon also stopped trusting its own memory over the filesystem. If you edit config.toml by hand and then run a command that would save the in-memory copy over it:
$ grove link api
✗ /Users/you/Library/Application Support/Grove/config.toml was changed on disk since the daemon last read it.
Run grove reload to pick up the edit, then retry — nothing was overwritten.
$ grove reload
✓ reloaded — 15 sites
$ grove link api
✓ linked api
grove reload is new. There was an IPC request for it, but no CLI verb, and it rebuilt from memory anyway, so a hand edit was invisible until a restart.
And after a kill -9 — a crash, a reboot mid-write, an impatient developer — the next boot cleans up after the last one:
WARN terminating orphaned process from a previous run pid=7889 file=…/run/fpm/php-fpm-8_4.pid
WARN reaped orphaned php-fpm masters count=1 pids=[7889]
It finds php-fpm masters and databases by their pid files, checks that the pid is alive and still running the expected binary — pids get recycled, and a stale file can name your editor — and stops them before spawning anything. Previously a restart spawned duplicates on the same socket, or failed the port bind and reported the database as "not running" while the orphan kept serving. A second grove daemon now refuses to start over a live one instead of unlinking its socket, and grove stop only signals a pid that is alive and is Grove.
The CLI says what happened, not what it meant
grove uninstall without sudo printed "service, resolver and CA trust removed". Every step was let _ =. Four no-ops and a success message. Now:
$ grove uninstall
Error: uninstalling removes the system service, the resolver and the CA trust,
which needs elevation — nothing was changed. Run sudo grove uninstall.
With sudo it stops the daemon first, reports each step, exits non-zero if any failed, and tells you it left your data alone — --purge removes GROVE_HOME and the PATH shims too.
grove init exited 0 when the PHP download failed, so a script wrapping it saw success. Real failures are now ✗ and exit 1; the "needs elevation" notice is advice and doesn't:
$ grove init --php 9.9
Grove setup:
✓ parked ~/Code (existing projects auto-imported)
✓ created config at /Users/you/Library/Application Support/Grove/config.toml
✓ root CA at /Users/you/Library/Application Support/Grove/certs/grove-ca.pem
✗ PHP install failed: no static PHP-FPM build found for version 9.9 (macos-aarch64)
! resolver + CA trust need elevation — run sudo grove init or sudo grove ca trust
grove path show said "Grove's toolchain is on your PATH" when Homebrew's php was earlier in it and won every time. It checked membership, not order:
$ grove path show
Grove's toolchain is on your PATH (/Users/you/.grove/bin) — but too late in it.
php currently resolves to /opt/homebrew/bin/php first. Move /Users/you/.grove/bin before it, e.g.:
export PATH="/Users/you/.grove/bin:$PATH" # at the *end* of your profile, so it wins
And the CLI checks the daemon's version before every command. Upgrade the binary, forget to restart, and you used to get "connection closed before a full message was received". Now:
! the Grove daemon is version 1.5.0 and this CLI is 1.6.0 — run grove restart so they match
Linux, honestly
Grove's core has always built and tested on Linux. The OS integration had not: the service was a systemctl --user unit that could never bind ports 53, 80 or 443; the resolver setup referenced a network link nothing created; and the CA went into the system store only — which Chrome and Firefox on Linux do not read, so the padlock stayed red however green curl was.
1.6.0 replaces all three. sudo grove install writes a system unit at /etc/systemd/system/grove.service (root, like the macOS LaunchDaemon; every child dropped to your user), creates a grove0 dummy link for systemd-resolved and routes your TLD to Grove's DNS through it — recreated on every boot — and installs the CA into your distro's store and into Chrome's and Firefox's NSS databases via certutil.
We are calling it beta, and we mean it: every system-touching step is built as a plan of commands and unit-tested, but we developed this where systemd isn't, and the first sudo grove install on a real Ubuntu or Fedora is where it gets its verdict. The code says exactly what it assumes — resolvectl present, libnss3-tools for the browsers — and what to do when that doesn't hold. The README badge now says macOS | Linux (beta), and no longer mentions Windows, because nothing there worked end to end.
What we got wrong on the way
Because the release is about honesty, a short list:
HTTP/2 routed every request as host
""until the smoke test caught it.Two of our own tests could only pass as non-root with procps
psinstalled. They now say so and skip.A process-name test raced
execon Linux —/proc/<pid>/commstill showed the parent — and turnedmainred for forty minutes.We told ourselves the release token was broken and needed replacing. It wasn't. The Actions token created this release, as it had every one before 1.5.0. The pre-flight step we added tells you in seconds if that ever changes.
Upgrading
Six things worth knowing, in the changelog's words:
grove doctorexits non-zero when anything fails — scripts gating on it start failing where they should have.Secured sites redirect
http://tohttps://; scripts and webhooks should use the HTTPS URL.grove uninstallneedssudo, and leaves your data unless you pass--purge.grove initexits 1 when a step fails.Linux: re-run
sudo grove install; the unit moved.After upgrading the binary,
grove restart. The CLI will nag you until you do.
Get it
Grove 1.6.0 is on the releases page: a notarized macOS app that updates itself in place, .deb, .rpm and AppImage for Linux, and the bare CLI for both. The full changelog is in the repo.
Then run grove doctor. If everything is ✓, that is Grove telling you the truth — which, this release, is the whole point.