Version
LifeOS 7.40.4 (verified against 5e2f2e8)
What is broken
Two related things in the Pulse daemon. First, Pulse never notices that its source has moved ahead of the running process. It is a long-lived launchd job with KeepAlive, so editing anything under LIFEOS/PULSE/ changes the file and nothing else: the process keeps serving whatever it loaded at start, and no log line, health field or dashboard tile says the two have diverged. A fix committed to a Pulse file therefore has no effect until somebody remembers to restart the daemon, and if nobody does, the original symptom keeps being logged after the bug is marked fixed. Second, and only visible once something tries to act on the first, returning from main() does not end the process. The cleanup block closes the HTTP server and fires the module stops without awaiting them, so a polling loop can hold the event loop open, and there is no explicit exit afterwards. Nothing notices today because every stop comes from launchd, which follows its SIGTERM with a SIGKILL. Anything that tries to exit deliberately instead gets a daemon that survives with its server already closed: listening on nothing, and never restarted, because from launchd's point of view it is still running.
Where (file:line)
LIFEOS/PULSE/pulse.ts:1208-1214 (cleanup closes the server, fires stopIMessage and stopAssistant unawaited, then returns with no exit; the only process.exit on the success path is absent, line 1218 being the crash handler). The staleness gap is the absence of any check in the cron loop that begins at LIFEOS/PULSE/pulse.ts:597.
Repro on a clean tree
# 1. Staleness. Start Pulse however the install normally does, then note the pid.
launchctl list com.lifeos.pulse # or: cat LIFEOS/PULSE/state/pulse.pid
# 2. Add an unmistakable line to a file the daemon loaded at start.
echo 'log("info", "STALENESS PROBE")' >> LIFEOS/PULSE/modules/wiki.ts
# 3. Wait past a full cron tick, then look for it.
grep -c 'STALENESS PROBE' LIFEOS/PULSE/logs/pulse-stdout.log
# 0 — indefinitely, and the pid is unchanged
# 4. The only thing that picks it up is a manual restart.
launchctl kickstart -k gui/$(id -u)/com.lifeos.pulse
For the second part, the observable shape is the cleanup block itself: server.stop() runs, stopIMessage/stopAssistant are fired without await, and main() returns without process.exit. Under launchd this is invisible because the kill arrives regardless.
Negative control
On unpatched 7.40.4, step 3 above returns 0 for as long as the daemon runs, with the process start time never changing, while the source on disk contains a line the running code does not have. There is no warning anywhere in the log, so the state is indistinguishable from a correctly updated daemon.
For the exit path, my evidence is from a fork rather than this tree, and I would rather say so than dress it up: adding a staleness check to the cron loop that set the shutdown flag and broke out of it produced a process that logged LifeOS Pulse stopped, closed port 31337, and then stayed alive indefinitely with launchd still counting it as running. The daemon served nothing until it was kickstarted by hand. The cleanup block in this tree is the same shape, minus the module whose polling held the loop open, so I can confirm the missing exit but not that this tree has a holder today.
Suggested fix
For the staleness gap, WatchPaths is the obvious answer and I would argue against it: man 5 launchd.plist calls it highly discouraged, filesystem monitoring is race-prone so modifications can be missed, and a caught modification gives no guarantee the file is consistent when the job relaunches, which against KeepAlive is a throttled crash loop. It would also need an entry per directory and would miss imports from outside the Pulse tree.
Asking the question inside Pulse works better, on the cron tick that already runs: walk the module graph reachable from the entrypoint by relative import, compare the newest mtime against state.startedAt, and when the source is ahead, log which file and exit so launchd reloads on the new code. That follows the real dependency graph rather than a hand-maintained directory list, never fires on a half-written file because the daemon chooses its own moment, and self-corrects on the next tick if a change is missed. A small tolerance window stops a write moments before launch reading as newer than the process it was already loaded by, and deferring while a message turn is in flight stops it dropping a reply someone is waiting on.
That fix depends on the exit being explicit, which is the other half of this report.
Running in a fork with 12 tests over the graph walk and the stale decision; measured detection latency is bounded by the existing tick sleep, and relaunch took 1.1 seconds. Happy to open a PR if it would be useful.
Version
LifeOS 7.40.4 (verified against
5e2f2e8)What is broken
Two related things in the Pulse daemon. First, Pulse never notices that its source has moved ahead of the running process. It is a long-lived launchd job with
KeepAlive, so editing anything underLIFEOS/PULSE/changes the file and nothing else: the process keeps serving whatever it loaded at start, and no log line, health field or dashboard tile says the two have diverged. A fix committed to a Pulse file therefore has no effect until somebody remembers to restart the daemon, and if nobody does, the original symptom keeps being logged after the bug is marked fixed. Second, and only visible once something tries to act on the first, returning frommain()does not end the process. The cleanup block closes the HTTP server and fires the module stops without awaiting them, so a polling loop can hold the event loop open, and there is no explicit exit afterwards. Nothing notices today because every stop comes from launchd, which follows itsSIGTERMwith aSIGKILL. Anything that tries to exit deliberately instead gets a daemon that survives with its server already closed: listening on nothing, and never restarted, because from launchd's point of view it is still running.Where (file:line)
LIFEOS/PULSE/pulse.ts:1208-1214(cleanup closes the server, firesstopIMessageandstopAssistantunawaited, then returns with no exit; the onlyprocess.exiton the success path is absent, line 1218 being the crash handler). The staleness gap is the absence of any check in the cron loop that begins atLIFEOS/PULSE/pulse.ts:597.Repro on a clean tree
For the second part, the observable shape is the cleanup block itself:
server.stop()runs,stopIMessage/stopAssistantare fired withoutawait, andmain()returns withoutprocess.exit. Under launchd this is invisible because the kill arrives regardless.Negative control
On unpatched 7.40.4, step 3 above returns
0for as long as the daemon runs, with the process start time never changing, while the source on disk contains a line the running code does not have. There is no warning anywhere in the log, so the state is indistinguishable from a correctly updated daemon.For the exit path, my evidence is from a fork rather than this tree, and I would rather say so than dress it up: adding a staleness check to the cron loop that set the shutdown flag and broke out of it produced a process that logged
LifeOS Pulse stopped, closed port 31337, and then stayed alive indefinitely with launchd still counting it as running. The daemon served nothing until it was kickstarted by hand. The cleanup block in this tree is the same shape, minus the module whose polling held the loop open, so I can confirm the missing exit but not that this tree has a holder today.Suggested fix
For the staleness gap,
WatchPathsis the obvious answer and I would argue against it:man 5 launchd.plistcalls it highly discouraged, filesystem monitoring is race-prone so modifications can be missed, and a caught modification gives no guarantee the file is consistent when the job relaunches, which againstKeepAliveis a throttled crash loop. It would also need an entry per directory and would miss imports from outside the Pulse tree.Asking the question inside Pulse works better, on the cron tick that already runs: walk the module graph reachable from the entrypoint by relative import, compare the newest mtime against
state.startedAt, and when the source is ahead, log which file and exit so launchd reloads on the new code. That follows the real dependency graph rather than a hand-maintained directory list, never fires on a half-written file because the daemon chooses its own moment, and self-corrects on the next tick if a change is missed. A small tolerance window stops a write moments before launch reading as newer than the process it was already loaded by, and deferring while a message turn is in flight stops it dropping a reply someone is waiting on.That fix depends on the exit being explicit, which is the other half of this report.
Running in a fork with 12 tests over the graph walk and the stale decision; measured detection latency is bounded by the existing tick sleep, and relaunch took 1.1 seconds. Happy to open a PR if it would be useful.