<p>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.</p><p>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.</p><p>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.</p><h2>The bug that hid in a correct-looking place</h2><p>Start with the one real bug, because it explains why we ended up doing the rest.</p><p>When a php-fpm master died — an OOM, a stray <code>killall</code>, 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.</p><p>The old pool and the new pool have the same socket path.</p><p>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.</p><p>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.</p><p>That was the moment the release got its theme. Grove had recovered from the crash and then, silently, un-recovered.</p><h2>Status and doctor say what actually happened</h2><p>Before 1.6.0, <code>grove status</code> printed <code>● dns</code> from a hardcoded <code>true</code>. A failed bind was one line in <code>daemon.log</code>. If another server held port 80 you got a green light and nothing served.</p><p>Now every listener records how its bind went, and the OS is asked who has the port:</p><pre><code class="language-text">$ 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
</code></pre><p><code>grove doctor</code> 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:</p><pre><code class="language-text">$ 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
</code></pre><p>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.</p><h2>The proxy grows up</h2><p>Grove is the web server for your sites, so this is where most of the day-to-day lives.</p><p><strong>HTTP/2.</strong> The TLS listener offered only <code>http/1.1</code> 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:</p><pre><code class="language-text">$ curl -sI --http2 https://myapp.test/
HTTP/2 200
</code></pre><p>We will be honest about how that went. Enabling h2 was green in every unit test and returned <code>404 no site registered for host ""</code> on the first real request. HTTP/2 has no <code>Host</code> header — the host travels as <code>:authority</code> — 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.</p><p><strong>Secured sites redirect.</strong> You ran <code>grove secure myapp</code>. Then you typed <code>myapp.test</code> into a browser, which defaults to <code>http://</code>, and Grove served it in plaintext. PHP saw <code>HTTPS=""</code>, Laravel built <code>http://</code> asset URLs, and you got mixed-content warnings against your own HTTPS Vite server. Now:</p><pre><code class="language-text">$ curl -sI http://myapp.test/admin?tab=2
HTTP/1.1 301 Moved Permanently
location: https://myapp.test/admin?tab=2
</code></pre><p>301 for GET and HEAD so browsers remember, 308 for everything else so a POST keeps its method and body.</p><p><strong>WebSockets pass through.</strong> 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 <code>wss://</code> — 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.</p><p><strong>Error pages that name the fix.</strong> Every error Grove generated itself was a bare <code>text/plain</code> line. When your Vite server wasn't running, the browser showed:</p><pre><code class="language-text">Grove: client error (Connect): tcp connect error: Connection refused (os error 61)
</code></pre><p>Now you get a page that says what happened and, when Grove knows, what to do:</p><pre><code class="language-text">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
</code></pre><p>An unknown host offers <code>grove link</code>. A missing PHP version offers <code>grove php install</code>. A stopped container says which one.</p><p><strong>Static files behave.</strong> Four small things that were each a ten-minute detour:</p><p>The SPA fallback served <code>index.html</code> as <code>200 text/html</code> 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 <code>&lt;video&gt;</code> because Grove never answered 206; single <code>Range</code> requests work now, with <code>Accept-Ranges</code> advertised. Compressible assets are gzipped when the browser asks:</p><pre><code class="language-text">$ 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
</code></pre><p>(That file was 4501 bytes uncompressed. The <code>-gz</code> on the ETag matters: a different representation needs a different validator, and the 304 path accepts either.) And <code>mime_for</code> finally knows about video, audio, fonts beyond woff, XML, PDF, AVIF, CSV, source maps and web manifests — sitemaps used to download instead of render.</p><p><strong>PHP gets dev-sized defaults.</strong> Grove writes no <code>php.ini</code>, 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 <code>request_terminate_timeout</code> 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 <code>php_value</code>, not <code>php_admin_value</code>, so your app's own <code>ini_set()</code> still wins.</p><h2>State that survives a bad day</h2><p>Every state file Grove owns — <code>config.toml</code>, <code>php-builds.json</code>, 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 <code>php-builds.json</code> into an empty registry, which Grove then saved over the file. Every PHP you had installed through Grove, forgotten, silently.</p><p>Writes are now atomic: temp file, fsync, rename. A file that exists but doesn't parse is moved aside as <code>&lt;name&gt;.corrupt-&lt;timestamp&gt;</code>, logged at error level, and reported by <code>grove doctor</code> until you deal with it. Your bytes are still there.</p><p>The daemon also stopped trusting its own memory over the filesystem. If you edit <code>config.toml</code> by hand and then run a command that would save the in-memory copy over it:</p><pre><code class="language-text">$ 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
</code></pre><p><code>grove reload</code> 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.</p><p>And after a <code>kill -9</code> — a crash, a reboot mid-write, an impatient developer — the next boot cleans up after the last one:</p><pre><code class="language-text">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]
</code></pre><p>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 <code>grove daemon</code> now refuses to start over a live one instead of unlinking its socket, and <code>grove stop</code> only signals a pid that is alive and is Grove.</p><hr><h2>The CLI says what happened, not what it meant</h2><p><code>grove uninstall</code> without <code>sudo</code> printed "service, resolver and CA trust removed". Every step was <code>let _ =</code>. Four no-ops and a success message. Now:</p><pre><code class="language-text">$ grove uninstall
Error: uninstalling removes the system service, the resolver and the CA trust,
which needs elevation — nothing was changed. Run `sudo grove uninstall`.
</code></pre><p>With <code>sudo</code> it stops the daemon first, reports each step, exits non-zero if any failed, and tells you it left your data alone — <code>--purge</code> removes <code>GROVE_HOME</code> and the PATH shims too.</p><p><code>grove init</code> 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:</p><pre><code class="language-text">$ 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`
</code></pre><p><code>grove path show</code> said "Grove's toolchain is on your PATH" when Homebrew's <code>php</code> was earlier in it and won every time. It checked membership, not order:</p><pre><code class="language-text">$ 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
</code></pre><p>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:</p><pre><code class="language-text">! the Grove daemon is version 1.5.0 and this CLI is 1.6.0 — run `grove restart` so they match
</code></pre><hr><h2>Linux, honestly</h2><p>Grove's core has always built and tested on Linux. The OS integration had not: the service was a <code>systemctl --user</code> 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 <code>curl</code> was.</p><p>1.6.0 replaces all three. <code>sudo grove install</code> writes a system unit at <code>/etc/systemd/system/grove.service</code> (root, like the macOS LaunchDaemon; every child dropped to your user), creates a <code>grove0</code> 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 <code>certutil</code>.</p><p>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 <code>sudo grove install</code> on a real Ubuntu or Fedora is where it gets its verdict. The code says exactly what it assumes — <code>resolvectl</code> present, <code>libnss3-tools</code> 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.</p><hr><h2>What we got wrong on the way</h2><p>Because the release is about honesty, a short list:</p><ul><li><p><strong>HTTP/2 routed every request as host </strong><code>""</code> until the smoke test caught it.</p></li><li><p><strong>Two of our own tests could only pass as non-root</strong> with procps <code>ps</code> installed. They now say so and skip.</p></li><li><p><strong>A process-name test raced </strong><code>exec</code><strong> on Linux</strong> — <code>/proc/&lt;pid&gt;/comm</code> still showed the parent — and turned <code>main</code> red for forty minutes.</p></li><li><p><strong>We told ourselves the release token was broken</strong> 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.</p></li></ul><h2>Upgrading</h2><p>Six things worth knowing, in the changelog's words:</p><ul><li><p><code>grove doctor</code><strong> exits non-zero when anything fails</strong> — scripts gating on it start failing where they should have.</p></li><li><p><strong>Secured sites redirect </strong><code>http://</code><strong> to </strong><code>https://</code><strong>;</strong> scripts and webhooks should use the HTTPS URL.</p></li><li><p><code>grove uninstall</code><strong> needs </strong><code>sudo</code><strong>,</strong> and leaves your data unless you pass <code>--purge</code>.</p></li><li><p><code>grove init</code><strong> exits 1 when a step fails.</strong></p></li><li><p><strong>Linux: re-run </strong><code>sudo grove install</code><strong>;</strong> the unit moved.</p></li><li><p><strong>After upgrading the binary, </strong><code>grove restart</code><strong>.</strong> The CLI will nag you until you do.</p></li></ul><h2>Get it</h2><p>Grove 1.6.0 is on the <a target="_blank" rel="noopener noreferrer nofollow" href="https://elyracode.com/grove">releases page</a>: a notarized macOS app that updates itself in place, <code>.deb</code>, <code>.rpm</code> and AppImage for Linux, and the bare CLI for both. The full changelog is in the repo.</p><p>Then run <code>grove doctor</code>. If everything is ✓, that is Grove telling you the truth — which, this release, is the whole point.</p>