Skip to content

fix: various issues & improve performance - #269

Merged
0xbrayo merged 19 commits into
ActivityWatch:masterfrom
0xbrayo:fix/module-process-lifecycle
Sep 23, 2026
Merged

0xbrayo merged 19 commits into
ActivityWatch:masterfrom
0xbrayo:fix/module-process-lifecycle

Conversation

@0xbrayo

@0xbrayo 0xbrayo commented Sep 23, 2026

Copy link
Copy Markdown
Member

No description provided.

The parent-death pipe never worked: both ends lived in aw-tauri, so the
monitor thread died with the parent and could not signal anything. What
actually closed the write end was the module thread returning after its
child had already exited, at which point the monitor sent SIGTERM to the
old PID, which the OS may have already reused for an unrelated process.

The pipe was also created without O_CLOEXEC, so every later module
inherited earlier modules' pipe ends.

Remove it. On Linux, use PR_SET_PDEATHSIG in pre_exec, which does what
the pipe was meant to do. Windows keeps its job object; macOS has no
equivalent, which is no regression since the pipe never worked there.
aw-notify is spawned with both stdout and stderr piped, but stderr was
only read by wait_with_output() after stdout closed. aw-notify logs to
stderr, so once ~64 KiB of logs built up the child blocked on its next
write and stopped sending notifications.

Read stderr on its own thread while stdout is consumed, keeping only the
last 64 KiB for the crash log and the --output-only fallback check.
The spawn error branch checked the error for "No such option:
--output-only", but spawn() errors come from the OS and never contain
the child's usage output. The real fallback is the exit-code-2 stderr
check after the child exits, so remove the dead branch.
restart_count only ever increased, so three crashes spread over weeks
permanently disabled automatic restarts for a module. A module restarted
by hand after hitting the limit also never auto-restarted again, and
because "recovered" is keyed off restart_count > 0, every later start
showed the crash-recovery alert.

Reset the count when a module stops after running for at least 60s, and
when the user starts it from the tray.
The crash-restart thread slept 2-8s and then restarted the module
without re-checking state. If the user started and then stopped the
module during that window, the retry started it again anyway; if it
was started and crashed again, two retries could race to start it.

Track a per-module start generation, bumped whenever a start is
requested, and skip the retry if the module was started since the
crash or a shutdown is pending.
A second instance writes the lock file with create+truncate, which
usually fires both a Create and a Modify event. The first event removed
the file, so remove_file().expect() panicked on the second, killing the
watcher thread; later launches then no longer focused the window.

Ignore NotFound as an already-handled event, and log rather than panic
if showing the window fails.
The GUI setup duplicated prepare_aw_server() but opened the SQLite
datastore (taking its lock) before checking that the port was free, the
ordering prepare_aw_server() exists to avoid. It then panicked right
after a non-blocking error dialog, so the dialog was never seen.

Use prepare_aw_server() like mini mode does, and on failure show the
error and exit once the user dismisses it.
Generic modules only had stdout piped, so stderr was inherited and the
"Module X stderr:" line logged after a crash was always empty. The
real error went to aw-tauri's own stderr, usually /dev/null for a GUI
app.

Pipe stderr and drain it on a thread, keeping the last 64 KiB.
wait_with_output() kept everything a module printed to stdout in memory
until it exited, which for a watcher can be weeks, so any module that
writes to stdout grew aw-tauri's memory without limit.

Drain stdout on a thread like stderr and keep only the last 64 KiB,
which is all the crash log uses.
build_menu_item() asked the OS for the login-item state each time the
tray menu was rebuilt, which happens once at startup plus once per
module that starts. With the macOS AppleScript launcher each query runs
osascript against System Events, taking a hundred milliseconds or more.

Cache the last state read from or written to the OS and use it for menu
rebuilds. Explicit reads (the Tauri command and the toggle handler)
still go to the OS and refresh the cache.
The 32 MiB limit was only checked at startup. aw-tauri usually runs for
weeks, so the log could grow far past it in between, especially with a
crash-looping module logging its stderr.

Write through a small file wrapper that rotates once the file passes
the limit. fern flushes after every record, so rotating on flush never
splits a line across files.
Mini mode rebuilt the whole tray menu on every module start/stop and
deep-copied the modules snapshot out of its Arc (the manager had also
copied it just to wrap it in that Arc).

Mirror the GUI path: keep the check items and only rebuild when a module
joins the started group, otherwise just sync checkmarks. Pass the
snapshot through by ownership instead of cloning it.
discover_modules() runs on the main thread during setup and called
metadata() on every entry of every PATH directory before checking for
the aw- prefix. On a typical macOS PATH that is ~3,400 stat calls to find
~20 candidates.

Check the name first so only aw-* entries are stat'ed.
AppHandle is Clone and thread-safe, but it was stored behind a Mutex,
and callers held that lock across slow main-thread work: the tray menu
rebuild, notifications, showing windows. That serialized unrelated
threads behind UI calls for no benefit. update_tray_menu() also held
the HANDLE_CONDVAR lock for the whole menu build.

Store the AppHandle directly, and release the condvar lock once the
handle is known to be set. init_app_handle() now also notifies the
condvar; before, only a Drop impl on a static (which never runs) did.
Discovery used the directory entry's own metadata, which doesn't follow
symlinks. A symlink's mode is 0o777 on Linux (0o755 on macOS), so any
aw-* link counted as an executable module, even one pointing at a
non-executable or missing file, and symlinked aw-* directories were
never searched.

Stat the target instead, skipping broken links. The directory walk is
split out of discover_modules() so it can be tested without PATH or the
user config.
On macOS, std creates a child's stdio pipes with pipe() and only then
sets close-on-exec (Linux uses pipe2(O_CLOEXEC)). Modules are spawned
from their own threads, so at startup one module could inherit another
module's pipe ends. When the owning module exited, the pipe stayed open,
its reader never saw EOF, and Stopped wasn't sent until the other module
exited too, leaving a stale PID in the manager and no crash restart.

This predates the stderr capture, but piping stderr doubled the pipes
per module. Take a lock around spawn() so pipe creation and exec can't
interleave across module threads. The new stress test lost a Stopped
message in 2 of 3 runs without the lock and passes consistently with it.
With no sender application set, mac-notification-sys resolves one on the
first notification by compiling an AppleScript that looks up an app
named "use_default". In mini mode that runs on the main thread from the
tray event loop; it spun at ~95% CPU for over a minute and froze the
tray, triggered by the first module-crash notification.

Set the sender to our bundle identifier at startup so the lookup never
runs. With the fix, mini mode stays idle through module crash
notifications.
@greptile-apps

greptile-apps Bot commented Sep 23, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 5/5

The PR appears safe to merge, with no accepted new defects or outstanding previous findings.

Summary

This PR strengthens application and managed-module lifecycle behavior across desktop, mini, and daemon modes.

  • Adds bounded module-output capture, finite crash recovery with stable-run reset behavior, and platform-specific parent-death handling.
  • Improves autostart state verification and cache invalidation so startup tray state follows the operating-system registration.
  • Removes unnecessary application-handle locking and makes startup and single-instance failures more graceful.
  • Updates mini-tray state in place and adds continuous size-based log rotation.
  • Adds focused tests for restart accounting, output capture, module discovery, log rotation, concurrent process spawning, and macOS watchdog behavior.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    App[aw-tauri runtime] --> Server[Embedded ActivityWatch server]
    App --> Manager[Module manager]
    Manager --> Spawn[Spawn watcher module]
    Spawn --> Guard{Platform lifecycle guard}
    Guard -->|Linux| ParentSignal[PDEATHSIG]
    Guard -->|macOS| Watchdog[Watchdog process group]
    Guard -->|Windows| Job[Job object]
    Spawn --> Capture[Bounded stdout and stderr capture]
    Capture --> Exit{Module exits}
    Exit -->|Expected stop| Stopped[Remain stopped]
    Exit -->|Unexpected and retry available| Backoff[Exponential backoff]
    Backoff --> Spawn
    Exit -->|Retry limit reached| Alert[Report permanent stop]
Loading

Reviews (2) · Last reviewed commit: "fix(manager): stop modules on macOS when..."

Comment thread src-tauri/src/autostart.rs Outdated
Comment thread src-tauri/src/manager.rs
Comment thread src-tauri/src/manager.rs
Startup sync cached the requested state as soon as enable()/disable()
returned Ok, but those calls can silently do nothing (set_enabled reads
back for that reason). The tray checkmark is built from the cache, so
it could show "Start at login" enabled when it wasn't.

Read the OS back after the startup change, and clear the cache whenever
an apply fails so the next read goes to the OS.
Linux now uses PDEATHSIG and Windows a job object, but macOS had no way
to clean up modules if aw-tauri crashed, was force-quit or SIGKILLed
(the old pipe monitor never worked either), so they kept running
unsupervised.

Start a small /bin/sh watchdog as the leader of a new process group and
have every module join it. The watchdog polls aw-tauri's pid once a
second and, once it's gone, sends SIGTERM to the whole group, which also
catches helpers the modules spawned themselves (aw-watcher-window's
aw-watcher-window-macos). Verified by SIGKILLing a --testing daemon: all
modules and the watchdog exited within a few seconds.
@0xbrayo

0xbrayo commented Sep 23, 2026 •

Copy link
Copy Markdown
Member Author

🤖 Claude, on behalf of @0xbrayo

@greptile review

@0xbrayo 0xbrayo changed the title Fix/module process lifecycle fix: various issues & improve performance Sep 23, 2026
@0xbrayo
0xbrayo merged commit 5ac8edd into ActivityWatch:master Sep 23, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant