Stop the flow store leaking git zombies, honour VITE_API_URL from .env

`FlowStore._git` now passes `-c gc.auto=0`, so `git commit` no longer forks a
background `gc --auto` that reparents onto PID 1 and stays there unreaped. With
auto-gc off nothing packs on its own, so `_commit` runs a foreground `git gc`
every 500 commits — the trigger a long-lived seeding session actually reaches.

`docker/compose.yml` takes the frontend's `VITE_API_URL` build arg from the
environment, keeping `https://api.${DOMAIN}` only as the fallback. `setup.sh`
already derives the scheme from `ENVIRONMENT`, so an `up --build` that does not
layer `compose.local.yml` stops shipping a bundle that calls `https://api.localhost`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01StpRc2C6au1WJ1EUU7fsfu
This commit is contained in:
2026-08-23 16:49:46 +02:00
co-authored by Claude Opus 5
parent c81d6cb21a
commit ce465e7b0e
5 changed files with 46 additions and 24 deletions
+5
View File
@@ -11,6 +11,11 @@ FRONTEND_HOST=http://localhost:5173
# local, staging, production # local, staging, production
ENVIRONMENT=local ENVIRONMENT=local
# Baked into the SPA at build time: the address the interface calls. The
# workspace root's scripts/setup.sh derives it from DOMAIN and ENVIRONMENT —
# plain http for a local target, where nothing terminates TLS.
VITE_API_URL=http://api.localhost
PROJECT_NAME="Fluksio" PROJECT_NAME="Fluksio"
STACK_NAME=fluksio-app STACK_NAME=fluksio-app
+8 -22
View File
@@ -16,20 +16,12 @@ Deferring because out of scope is fine, but don't mention deferring than.
- FEAT/UI: check if PWA (https://whatpwacando.today/) notifications could be used to have a panel sending notifications to the device event bus (or generally using PWA to retrieve e.g. location etc). We could introduce a general concept of having a panel (a device, like a wall panel or a phone where the pwa (dashboard) runs) being effectively a node with various outputs. Then various inputs could trigger actions like authentification (i.e. you get home and get a notification which allows you to authenticate the door unlock), get notified on alarms (native alarm connector) or to query geolocation (check where the user is before turning of all lights) etc - FEAT/UI: check if PWA (https://whatpwacando.today/) notifications could be used to have a panel sending notifications to the device event bus (or generally using PWA to retrieve e.g. location etc). We could introduce a general concept of having a panel (a device, like a wall panel or a phone where the pwa (dashboard) runs) being effectively a node with various outputs. Then various inputs could trigger actions like authentification (i.e. you get home and get a notification which allows you to authenticate the door unlock), get notified on alarms (native alarm connector) or to query geolocation (check where the user is before turning of all lights) etc
- BUG/UI sync the theme state between panels and installations - BUG/UI sync the theme state between panels and installations
- BUG/UI some dashboard widgets (like the color picker) are scrollable; we should make sure that no widgets (except for text widgets or list-related widgets) are scrollable - BUG/UI some dashboard widgets (like the color picker) are scrollable; we should make sure that no widgets (except for text widgets or list-related widgets) are scrollable
- INFRA document `make update` in the docs; this command is intended to run as a fire-and-forget command when updating a local installation
- CHORE/UI: the house panels are laid out for 1280x800 — twelve columns, twelve - CHORE/UI: the house panels are laid out for 1280x800 — twelve columns, twelve
rows. A chart's fixed chrome is now its title line and legend: the range picker rows. A chart's fixed chrome is now its title line and legend: the range picker
moved up onto the title and gave back its row, so the budget is nearer forty moved up onto the title and gave back its row, so the budget is nearer forty
pixels than eighty and a third chart may now fit — worth measuring against the pixels than eighty and a third chart may now fit — worth measuring against the
panel before adding one. The temperature history was the one dropped; panel before adding one. The temperature history was the one dropped;
`history.climate_*` still answers, so it is a tile away. `history.climate_*` still answers, so it is a tile away.
- BUG/INFRA: `compose.yml` builds the frontend with `VITE_API_URL=https://api.${DOMAIN}`
and only `compose.local.yml` overrides it to `http`. Any `up --build` that does not layer
the local file therefore ships a bundle calling `https://api.localhost`, which fails with
`ERR_CERT_AUTHORITY_INVALID` and breaks login entirely — nothing terminates TLS locally.
It recurs every time the stack is brought up without the override, so it is not a stale
image but a default that is wrong for the local target. Either flip the default or make the
local target the one the Makefile always passes.
- FEAT/UI: a bar's nested reading is captioned by its port name, which is - FEAT/UI: a bar's nested reading is captioned by its port name, which is
chosen for the graph rather than for somebody reading it across a room. chosen for the graph rather than for somebody reading it across a room.
`Segment` now takes an optional `label`; the house dashboard sets one `Segment` now takes an optional `label`; the house dashboard sets one
@@ -51,14 +43,14 @@ Deferring because out of scope is fine, but don't mention deferring than.
`Supervisor.cancel_all` was just fixed for. Latent rather than live: a supervised `Supervisor.cancel_all` was just fixed for. Latent rather than live: a supervised
node leaves those handles `None`, so only a node built on its own (a test, a node leaves those handles `None`, so only a node built on its own (a test, a
preview) awaits there. preview) awaits there.
- BUG/INFRA: **475 zombie `git` processes** in the API container after a seeding - BUG/INFRA: **zombie `python` processes in the API container**, twelve of them in
session. `FlowStore._git` uses `subprocess.run`, which reaps its own child — these groups of four with matching elapsed times — one group per worker pool. `_Worker.kill()`
are the `git gc --auto` daemons `git commit` spawns, reparented to PID 1 when their waits on the process it kills, so these are workers orphaned when the process holding
parent exits. PID 1 is the FastAPI process, which never reaps orphans. Harmless at their `Popen` went away without stopping the pool, which is what uvicorn `--reload`
484 processes against a 34719 limit, but it grows with every save, and a container does on every source edit. They reparent onto PID 1, and PID 1 in the container is the
that commits per keystroke will get there. Fix: `git -c gc.auto=0 commit`, since a reloader, which reaps nothing that is not its own child. `init: true` on the backend
store that never packs is a separate (and real) concern — 1898 commits had left service hands PID 1 to an init that does reap orphans, and would close the whole class
5863 loose objects and no packs. rather than one source of it.
- PERF/API: seeding nineteen flows takes two reloads each — one to publish, one to - PERF/API: seeding nineteen flows takes two reloads each — one to publish, one to
stop — so a full rebuild of every flow in the installation runs about forty times stop — so a full rebuild of every flow in the installation runs about forty times
for one seed. It is the slowest thing about standing an installation up, and a for one seed. It is the slowest thing about standing an installation up, and a
@@ -424,12 +416,6 @@ Open on purpose. Each names what should bring it back.
with its schemas, so a generator (the way n3xd generates its command catalog) is the with its schemas, so a generator (the way n3xd generates its command catalog) is the
obvious fix once the type list stops moving. obvious fix once the type list stops moving.
- `make update`/`make up` (production overlay) runs `up -d --remove-orphans` under the
same `fluksio-app` project as `make dev`, so it silently deletes the local Traefik
container `fluksio-app-proxy-1`. With `REVERSE_PROXY=external` and no host proxy,
the whole stack then has nothing bound to :80 and `*.localhost` stops resolving.
Either drop `--remove-orphans` or make `update` refuse when `ENVIRONMENT=local`.
- CHORE: `frontend/vite.config.js` and `frontend/vite.config.d.ts` are committed build - CHORE: `frontend/vite.config.js` and `frontend/vite.config.d.ts` are committed build
output of `vite.config.ts` and fail `biome check` as tracked (semicolons, 4-space output of `vite.config.ts` and fail `biome check` as tracked (semicolons, 4-space
indent). The pre-commit hook therefore fails for anyone who stages any other file indent). The pre-commit hook therefore fails for anyone who stages any other file
+19 -1
View File
@@ -97,6 +97,13 @@ class FlowStore:
#: One warning per process when there is no git to commit with. #: One warning per process when there is no git to commit with.
_warned_no_git = False _warned_no_git = False
#: Commits between housekeeping runs. Auto-gc is off (see `_git`), so
#: nothing packs unless it is asked for; a commit counter is the trigger
#: because the process that needs it never restarts — a seeding session
#: commits thousands of times inside one long-lived container, which is how
#: a store reached 1898 commits, 5863 loose objects and no pack at all.
_GC_EVERY = 500
def __init__(self, root: Path) -> None: def __init__(self, root: Path) -> None:
self.root = root self.root = root
self.root.mkdir(parents=True, exist_ok=True) self.root.mkdir(parents=True, exist_ok=True)
@@ -120,7 +127,13 @@ class FlowStore:
def _git(self, *args: str) -> subprocess.CompletedProcess[str]: def _git(self, *args: str) -> subprocess.CompletedProcess[str]:
try: try:
return subprocess.run( return subprocess.run(
["git", "-C", str(self.root), *args], # `gc.auto=0` on every invocation, because `git commit` otherwise
# forks a background `gc --auto` and returns without waiting for
# it. `subprocess.run` reaps the commit, not its grandchild, so
# the daemon is reparented onto PID 1 — the FastAPI process,
# which reaps nothing, and the zombies accumulate one per save.
# Packing is asked for explicitly in `_commit` instead.
["git", "-C", str(self.root), "-c", "gc.auto=0", *args],
capture_output=True, capture_output=True,
text=True, text=True,
check=False, check=False,
@@ -158,6 +171,11 @@ class FlowStore:
message, message,
) )
if result.returncode == 0 or "nothing to commit" in result.stdout: if result.returncode == 0 or "nothing to commit" in result.stdout:
if self.revision % self._GC_EVERY == 0:
# In the foreground, so this child is waited on rather than
# orphaned. Plain `gc` rather than `gc --auto`, which the flag
# above has just disabled.
self._git("gc", "--quiet")
return return
if result.stderr == "git is not installed": if result.stderr == "git is not installed":
# Already said once, in `_git`. Repeating it per save is noise. # Already said once, in `_git`. Repeating it per save is noise.
+9 -1
View File
@@ -173,7 +173,15 @@ services:
context: .. context: ..
dockerfile: frontend/Dockerfile dockerfile: frontend/Dockerfile
args: args:
- VITE_API_URL=https://api.${DOMAIN?Variable not set} # The address baked into the bundle. `scripts/setup.sh` already derives
# it from ENVIRONMENT and writes it to .env — http for a local target,
# because nothing terminates TLS there — so take it from the environment
# and keep the https form only as the default for a checkout that
# predates that key. Hardcoding https here made every `up --build` that
# did not also layer compose.local.yml ship a bundle calling
# https://api.localhost, which fails the certificate check and with it
# the whole login.
- VITE_API_URL=${VITE_API_URL:-https://api.${DOMAIN?Variable not set}}
- NODE_ENV=production - NODE_ENV=production
labels: labels:
- traefik.enable=true - traefik.enable=true
+5
View File
@@ -65,6 +65,11 @@ The dashboard is a static bundle, so this one is a **build** argument of the
|---|---|---| |---|---|---|
| `VITE_API_URL` | `frontend` at build time | the address the interface calls | | `VITE_API_URL` | `frontend` at build time | the address the interface calls |
The compose stack takes it from `.env`, falling back to `https://api.${DOMAIN}`.
`scripts/setup.sh` writes it there with the scheme `ENVIRONMENT` implies, so a
local build calls `http://` and does not fail a certificate check nothing is
there to satisfy.
Empty is the useful value: the interface then addresses the API relative to Empty is the useful value: the interface then addresses the API relative to
whichever origin served the page, so one image answers on a hostname, on a whichever origin served the page, so one image answers on a hostname, on a
`http://<host-ip>:<port>`, and through an ssh tunnel alike — and no origin has `http://<host-ip>:<port>`, and through an ssh tunnel alike — and no origin has