ADR-0027: On-demand service lifecycle (manual start + idle stop + idle GC)
Date: 2026-06-23 / Status: Accepted (amends the “resident service” lifecycle decision of ADR-0016; the service split, transport, and security model are unchanged)
Decision
Stop running fmf-engine as a boot-time resident. Instead:
- Manual start — register the service
SERVICE_DEMAND_START(wasSERVICE_AUTO_START+ delayed). It no longer starts at every boot; it runs only when something starts it. - Unelevated start/stop — at install (one-time, elevated) set the service-object DACL to grant the authorized user SID(s)
SERVICE_START | SERVICE_STOP | SERVICE_QUERY_STATUS(and read), so the asInvoker app starts the service on launch with no UAC. Never grant a standard userSERVICE_CHANGE_CONFIG/DELETE/WRITE_DAC/WRITE_OWNER— on a LocalSystem service that is local privilege escalation. - App-launch start — SCM state is checked before probing: a definitively absent/stopped service pays no pipe timeout; only exact
SERVICE_STOPPEDmay route toStartThenPipe, while transitions/unreadable state stay pipe-only so in-proc cannot race the writer lock.Resolvestarts a marker-compatible stopped service unelevated and its supervisor waits for the pipe. Failure falls back to setup/re-registration. - Idle self-stop —
serve()stops itself afterservice.jsonidle_stop_secs(default 300 = 5 min) with no live pipe connection. The clock starts only after a client has connected and dropped; a self-stop is held off while an initial scan is in flight.0disables it (the legacy “stay resident once started” behaviour). - Idle GC — a daily SYSTEM Scheduled Task runs
fmf-service gc, which uninstalls the service + removes the task + purges the data whenlast_useis older thangc_max_idle_days(default 7,0disables). To survive the portable app folder being deleted, install copiesfmf-service.exeinto the hardened data root (%ProgramData%\find-my-files\fmf-service.exe) and points the registration and the task at that copy.
Rationale
- ADR-0016 chose a resident service so “the index stays fresh via USN tracking even when the UI is not running.” That is real, but this is a momentary-use tool: a permanent boot-time process holding the index in RAM forever does not match how it is used. The owner’s call is minimal footprint over an always-hot index.
- A
DEMAND_STARTservice that is stopped consumes zero RAM and zero CPU — it is just an inert SCM row. So manual-start + idle-stop fully solves the “resident forever, eats memory” concern; the idle GC is housekeeping (it removes the leftover registration/data and self-heals an orphaned install after the portable app is deleted — there is no installer/uninstaller to do it). - Granting the user
SERVICE_START/STOPon the service object is what makes the per-launch start UAC-free. It is a deliberate, minimal widening of the service ACL; the dangerous rights stay admin-only (see threats in docs/SECURITY.md). - A stopped service cannot run a timer, so the idle GC must be driven by an external scheduler. A Scheduled Task is the standard Windows mechanism and is far lighter than a resident process (it runs for milliseconds, only when it fires, and removes itself when the GC completes).
Trade-off
Abandoning residence means a cold start at the first search of a session: the snapshot is loaded and the USN journal replayed; if the journal has wrapped since the last run (long absence, heavy churn) it is a full rescan. Measured baselines (ADR-0016): restore→ready p50 108 ms, ~1.25 s including process spawn; full rescan ≈5 s/250k, ≈60 s/1M. Hot search p99 (<10 ms) is unchanged — the only new cost is one cold start per session.
Rejected alternatives
- In-proc only while the app is open (no service) — MFT/USN reads need elevation, so this is a UAC prompt on every launch. One-time install then UAC-free on-demand start is strictly better UX.
- Self-uninstall on idle instead of a Scheduled Task — a service that idle-stops after 5 min is never running at the 1-week mark to notice the absence. Time-based GC fundamentally needs an external scheduler.
- GC task / service pointing at the portable exe — deleting the app folder breaks both, leaving an un-GC-able orphan. The stable copy in
%ProgramData%is what makes “auto-delete after a week” actually work for the deleted-app case (and fixes the latent bug where the resident service’s binary path pointed into a deletable folder). Version skew between bundle and copy is detected by the pipe Hello handshake and self-heals on the next re-register.
Consequences
- No wire-contract / golden / ABI change: everything is SCM-, filesystem-, and Scheduled-Task-level, plus two additive
service.jsonfields (idle_stop_secs,gc_max_idle_days) read with serde#[serde(default)]. Observability is via the rolling engine logs (idle stop) andapp.log(on-demand start), not new counters (an idle-stop counter dies with the process). - Install now copies a binary into
%ProgramData%and registers a Scheduled Task; both are removed on teardown (see the cleanup guarantee below). - Machine footprint & cleanup guarantee. The footprint is exactly three things — an SCM service registration, one machine-wide data directory, and the GC Scheduled Task — and it is a closed set: nothing goes into HKCU, Program Files, the Start menu, firewall rules, or the Event Log (logs are files under the data dir). It returns to a clean machine two ways: (a) explicit, immediate — the app’s “Remove” + “Also delete the index and logs” runs
uninstall --purge-data, deleting service + task + data at once (uninstall runs from the bundle exe, so the stable copy is not in use);uninstallwithout purge removes the service, task, and the stable exe (program clutter), keeping only the user’s own index/logs/config; (b) automatic — the idle GC removes service + task + data, with the still-running stable exe and its now-empty dir scheduled for deletion on the next reboot (MoveFileExdelay-until-reboot, since a running image cannot delete itself). The per-user UI settings/logs are the UI’s, independent of the service. - The
%ProgramData%exe copy is a security requirement, not gratuitous footprint: the SCM launches it as LocalSystem, so it must live where a standard user cannot overwrite it (the user-writable portable folder would be a privilege-escalation vector now that start is unelevated). - The service-object DACL and the stable-exe/data-dir non-writability are new security-relevant surfaces — recorded in docs/SECURITY.md (threats 9–10) and pinned by the
service_sddlunit test (start/stop present; change-config/delete/write-DAC/write-owner absent), mirroring the pipe-SDDL pin. idle_stop_secsapplies to the consolerunpath too;just service-devusers who want it to stay up setidle_stop_secs = 0.
Verification
Executable tests pin the service DACL, idle/GC decisions, last_use, protocol
marker routing, and unelevated idle self-stop. Install-time checks enforce SCM
configuration, GC task registration, and stable-binary/data ACLs.
Re-examination triggers
- If cold start after a long absence (journal-wrapped full rescan) becomes a routine complaint, reconsider a low-footprint “freshness-only” mode (a lightweight USN-tail that keeps the snapshot current without serving), rather than reverting to a full resident server.
- If multi-user machines become a real target (already an ADR-0017 trigger), revisit who may start/stop the service (per-user vs. a group ACE).