# 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.

Reply via email to