Make orphaning structurally impossible: the process is the state

The extension used to record the spawned pid in LocalStorage, which meant
two pieces of state had to stay in sync: the record and the process. When
the record was lost (extension reinstalled, storage cleared, a crash
between spawn and save) the process kept holding a sleep assertion that
nothing could ever release — a machine that silently never sleeps. That
happened for real during development.

Fix it by removing the thing that can be lost rather than guarding it:
the process now carries its own identity, so the OS is the single source
of truth.

Each session is tagged in its own command line:

  vicinae-caffeinate:v1:<startedAt>:<flags>   e.g. ...:1789895271765:id

- macOS: caffeinate has no free-form field, so the marker is installed as
  argv[0] via the shell's `exec -a`. exec replaces the shell in place, so
  the pid Node receives is the caffeinate pid. (Verified that both `ps -o
  command=` and `ps -o comm=` surface the custom argv[0].)
- Linux: the marker rides in systemd-inhibit's --why next to a
  human-readable reason.

status is now a `ps` scan for the marker; stop signals every match. This
deletes lib/state.ts and every LocalStorage call — the extension is
stateless, which also retires the Vicinae null-vs-undefined storage quirk
as a hazard for this codebase.

Also:
- Signal the process group (-pid) rather than a single pid. Sessions are
  spawned detached so the child is a group leader, and on Linux
  systemd-inhibit forks a `sleep` that a parent-only signal can leave
  behind.
- start() clears existing sessions first, so assertions cannot stack.
- If the spawn never registers a marker, roll back instead of leaving a
  half-started session.
- Drop the now-meaningless "stale" concept; add `extra` so an unexpected
  duplicate surfaces in the status view instead of being invisible.
- Add describeFlags() to render flag letters for humans.

Verified by deleting the installed extension while a session was active,
restarting Vicinae, then reinstalling: the fresh install saw the leftover
as ACTIVE and stopped it rather than starting a second and orphaning the
first. A user-owned `caffeinate -u -dmi` was untouched throughout.

vici lint, tsc, eslint, 17/17 vitest, vici build all green; live toggle
on/off round-trips cleanly.
This commit is contained in:
2026-09-20 17:08:19 +08:00
parent a303e161d3
commit 5ca37fbc79
7 changed files with 360 additions and 182 deletions
+27 -4
View File
@@ -15,7 +15,7 @@ This is a native `@vicinae/api` extension, so none of that applies.
| Command | Mode | What it does |
| --- | --- | --- |
| **Toggle Caffeination** | no-view | Flips sleep prevention on or off, whichever is appropriate. The fast path. |
| **Caffeination Status** | view | Whether sleep is being blocked, since when, and the exact command holding it. Start/stop from here. |
| **Caffeination Status** | view | Whether sleep is being blocked, since when, and the exact process holding it. Start/stop from here. |
## How it works
@@ -30,11 +30,34 @@ Both run until signalled — there is no timer, so nothing needs to be refreshed
The process is spawned **detached and unref'd**. That is required: Vicinae unloads a `no-view` command as soon as its function returns, so a child process would otherwise be torn down with it.
State is recorded in `LocalStorage` as `{ pid, startedAt, command, args, processName, … }`.
## The process is the state
Stopping signals the **recorded PID**, not `killall caffeinate`, so a `caffeinate` you started yourself in a terminal is left alone. Before signalling, the PID's process name is re-checked via `ps -o comm=` — PIDs get recycled, and without that check a stale entry could kill an unrelated process.
**Nothing is persisted.** The running process carries its own identity in its command line:
Status is self-healing: if the recorded process is gone (killed elsewhere, or a reboot), the stale state is cleared on the next read.
```
vicinae-caffeinate:v1:<startedAt>:<flags> e.g. vicinae-caffeinate:v1:1789895271765:id
```
- **macOS** has no free-form field on `caffeinate`, so the marker is installed as `argv[0]` via the shell's `exec -a`. `exec` replaces the shell in place, so the pid Node receives *is* the `caffeinate` pid.
- **Linux** passes it in `systemd-inhibit`'s `--why`, alongside a human-readable reason.
Status is therefore a `ps` scan for that marker, and stopping signals every match.
### Why this design
The obvious approach — remember the pid in `LocalStorage` — has a failure mode that is worse than it sounds. Two pieces of state have to stay in sync: the record, and the actual process. If the record is ever lost (extension reinstalled, storage cleared, a crash between spawning and saving) the process keeps holding a sleep assertion that **nothing will ever release**. A laptop that silently never sleeps, with no way to find the culprit.
Making the process self-describing removes the class of bug rather than guarding against it: there is no record to lose, so the extension cannot lose track of a session. Any `status` or `toggle` reconciles against the truth, and `start` always clears existing sessions first so assertions cannot stack.
Verified by deleting the installed extension while a session was active, restarting Vicinae, then reinstalling: the fresh install saw the leftover as **active** and stopped it, instead of starting a second one and orphaning the first.
Signals go to the **process group** (`kill -TERM -<pid>`). Sessions are spawned detached, so the child is a group leader; on Linux that matters because `systemd-inhibit` forks a `sleep`, and signalling only the parent can leave the child behind.
A `caffeinate` you started yourself is never touched — only processes carrying the marker are ever signalled. If a session ever does need to be cleared by hand:
```sh
pkill -f vicinae-caffeinate
```
## Preferences