# GNOME Contacts Search Provider 100% CPU — Diagnosis
This is a AI-made diagnosis, which does point to libfolks O(N²) behavior.
## Affected Packages
- `gnome-contacts` 50.0-2
- `libfolks` 0.15.12-1+b1
- `libglib2.0-0` (GLib signal handler lookup)
- Debian sid (forky), x86_64
## Summary
The `gnome-contacts-search-provider` process spins at 100% CPU for
several minutes during startup when many online contacts accounts are
configured. The root cause is an **O(N²) algorithm** in GLib's signal
handler lookup (`handler_lookup_by_closure()`) triggered by libfolks'
individual aggregation when processing a large number of personas in a
single batch.
With 26,402 personas from 5 GNOME Online Accounts, the process performs
~676 million comparison operations, all in userspace with zero I/O.
## How to Reproduce
1. Configure 2+ GNOME Online Accounts with large contact lists
(e.g. Google CardDAV, Nextcloud, Microsoft 365).
2. Enable contacts sync for all accounts.
3. Launch GNOME Contacts or trigger a contacts search.
4. Observe `gnome-contacts-search-provider` at 100% CPU (`top`/`htop`)
with state `Rl` and ~500 MB RSS.
5. The process eventually reaches quiescent state, but may take
several minutes.
## Observation
### Process State
```
PID: 87405 PPID: 75437 (systemd --user)
Binary: /usr/libexec/gnome-contacts-search-provider
CPU: 94.8% MEM: 1.7% (500 MB RSS)
State: Rl (running, threaded)
Threads: 7 (only main thread spinning)
```
### I/O Analysis (strace -c, 3 seconds)
Only **1 `brk` syscall** in 3 seconds — confirmed pure CPU-bound
userspace loop, not I/O blocked.
### Accounts Involved
5 GNOME Online Accounts with contacts enabled:
| Account | Backend | Notes |
|------------------------------|---------------|----------------------|
| account1 | Google CardDAV| Contacts enabled |
| account2 | Nextcloud | Contacts enabled |
| account3 | Microsoft 365 | **Empty URI** |
| account4 | Google CardDAV| Contacts enabled |
| account5 | IMAP | Contacts enabled |
The local EDS addressbook
(`~/.local/share/evolution/addressbook/system/contacts.db`)
contains only 1 contact; the bulk comes from online accounts.
## Root Cause Analysis
### The Call Chain
```
_edsf_persona_store_contacts_complete_idle_cb()
→ _emit_personas_changed(_pending_personas) ← emits ALL 26,402 personas
→ IndividualAggregator._personas_changed_cb()
→ _add_personas(added)
→ for each persona:
→ new Individual(final_personas) ← creates Individual
→ linkable_property_to_links(lambda) ← block3_data closure
→ (lambda finalized)
→ block3_data_unref()
→ gee_hash_set_clear(candidate_inds)
→ g_object_unref(Individual)
→ g_object_finalize()
→ closure_array_destroy_all() ← Individual's closure_array
→ g_closure_invalidate()
→ invalid_closure_notify(instance=EdsfPersonaStore)
→ handler_lookup_by_closure() ← LINEAR SCAN
```
### Step-by-Step Explanation
**Step 1: Bulk persona emission**
`EdsfPersonaStore._contacts_complete_idle_cb()` (edsf-persona-store.vala:2648)
fires when the initial EDS/CardDAV query completes. During the query,
`_contacts_added_idle()` accumulates all arrived personas in
`_pending_personas`. When complete, `_contacts_complete_idle_cb()` emits
them all in a single `personas-changed` signal:
```vala
// edsf-persona-store.vala:2670-2672
if (this._pending_personas != null)
{
this._emit_personas_changed (this._pending_personas, null);
this._pending_personas = null;
}
```
This sends all 26,402 personas in one signal emission.
**Step 2: Individual aggregation**
`IndividualAggregator._personas_changed_cb()` (individual-aggregator.vala:1609)
receives the full set and calls `_add_personas()` (line 1696). For each
persona, it:
1. Searches for linking candidates via IID and linkable properties
(lines 1228-1316)
2. Creates a new `Individual` with the merged persona set (line 1344)
3. Runs the `linkable_property_to_links` lambda (line 1288) which
captures `candidate_inds` (a `HashSet<Individual>`)
**Step 3: Per-Individual store signal connections**
When the `Individual` constructor adds a 2nd+ persona from the same
`PersonaStore`, it connects to the store's signals
(individual.vala:2937-2944):
```vala
// individual.vala:2932-2944
var num_from_store = this._stores.get (store);
if (num_from_store == 0)
{
this._stores.set (store, num_from_store + 1);
}
else
{
this._stores.set (store, 1);
store.removed.connect (this._store_removed_cb);
store.personas_changed.connect (
this._store_personas_changed_cb);
}
```
Each such connection adds a `Handler` to the `EdsfPersonaStore`'s
signal handler list and a `GClosure` to the store's closure array.
**Step 4: Individual replacement and unref**
When linking merges Individuals, old ones are replaced. The lambda's
`block3_data` captures `candidate_inds` containing the old Individual.
When the lambda's refcount drops to 0:
```vala
// Vala-compiled: block3_data_unref()
// → gee_hash_set_clear(candidate_inds)
// → g_object_unref(old_individual)
```
The old `Individual`'s `g_object_finalize()` destroys its closure array.
Each closure connected to the `EdsfPersonaStore`'s signals is
invalidated, calling `invalid_closure_notify(instance=store)`.
**Step 5: The O(N²) bottleneck**
`handler_lookup_by_closure()` (GLib gsignal.c:484) is called for each
closure invalidation. It does a **linear scan of ALL handler lists**
for the `EdsfPersonaStore`:
```
handler_lookup_by_closure(instance=EdsfPersonaStore, closure)
→ hash_table_lookup(g_handler_list_bsa_ht, instance)
→ for each signal (3 signals):
→ walk entire handler linked list
→ compare handler->closure == target closure
```
With the `EdsfPersonaStore` having thousands of handlers (from N
Individuals each contributing connections), each scan is O(N).
With N closures to invalidate, total work is **O(N²)**.
## Quantitative Data
| Metric | Value |
|--------------------------------------|----------------|
| Personas loaded | 26,402 |
| Total handlers (g_handlers) | ~199,745 |
| Handler list instances | ~30,091 |
| EdsfPersonaStore signal lists | 3 (signals 1, 82, 83) |
| Estimated total comparisons | ~676 million |
| I/O syscalls in 3 seconds | 1 (brk) |
| Process RSS | 500 MB |
### EdsfPersonaStore State
- `_is_prepared = 1` (store is prepared)
- `_is_quiescent = 0` (still loading contacts — stuck in the loading loop)
- `_personas` count: 26,402
- Store ID: `edsf-persona-store:system`
## Why It Doesn't Finish Quickly
The process **is** making progress (confirmed via snapshots taken 2
seconds apart showing different closure addresses), but each step
does O(N) work in a brute-force linear scan. With 26,402 personas,
what should take seconds instead takes many minutes.
## Potential Fixes
### Short-term (libfolks)
1. **Batch persona emissions** in `_contacts_complete_idle_cb()`
instead of emitting all 26K+ at once. Emitting in chunks of ~1000
would reduce the peak handler count and prevent the O(N²)
explosion.
2. **Avoid redundant store signal connections** — the `else` branch
at individual.vala:2937 reconnects to `store.personas_changed`
and `store.removed` even though the count is already 1 (it does
`this._stores.set(store, 1)` instead of incrementing). This causes
duplicate handler connections for each subsequent persona from the
same store.
### Long-term (GLib)
3. **Hash table index for closure lookup** —
`handler_lookup_by_closure()` could maintain a secondary
`GClosure* → Handler*` hash table for O(1) lookup instead of the
current O(H) linear scan of all handler lists for an instance.
## Workarounds
1. **Kill the process**: `kill $(pidof gnome-contacts-search-provider)`
— it will restart on next search.
2. **Disable contacts search**: Settings → Search → toggle Contacts OFF.
3. **Reduce online accounts**: Remove accounts with large contact lists
from GNOME Online Accounts.
4. **Wait**: The process will eventually reach quiescent state and stop
spinning, but may take several minutes.