st2 up can spend its entire startup traversing Resource payloads before its first reconciliation pass. If watcher installation then fails, the intended timer fallback can also continuously reconcile instead of honoring its interval.
Reproduction
https://github.com/schickling-repros/2026-08-st2-notify-recursive-resource-tree
The synthetic catalog has four declaration-space directories and a 512-directory Resource payload. On Linux with notify 8.2.0, recursive registration allocates a watch for the payload directories as well:
recursive watches: 519
declaration-only shallow watches: 4
In the motivating deployment, the inotify descriptor grew past 193,000 watches while registration followed a Resource worktree into an external dependency tree. The first reconcile did not begin for roughly 20 minutes. When recursive installation finally returned/dropped, the watch count fell to four and timer polling reconciled the pending state.
Root cause
Unbounded installation
watch_catalog_declarations filters callback paths, but it first calls watcher.watch(root, RecursiveMode::Recursive). The Linux backend eagerly walks the full tree and allocates inotify watches before any callback filtering occurs. Catalog Resource directories may contain arbitrarily large worktrees and dependency trees, including links outside the declaration namespace.
Disconnected fallback bypasses the interval
If best-effort watcher installation fails, its channel sender is dropped. The receiver then returns RecvTimeoutError::Disconnected immediately. The old wait loop treats that as the end of the wait and starts another full reconcile, so the nominal timer fallback can become a continuous reconcile loop.
The installation error was also discarded behind Option, making both the loss of immediate wakeups and the cadence failure hard to diagnose.
Expected
- Watcher installation scales with declaration-space directories, not Resource payloads.
- Resource/worktree links cannot escape the catalog watch boundary.
- Declaration creation, update, rename, and removal still wake reconciliation.
- Installation failures are visible.
- When no watcher is available, reconciliation waits for the configured timer interval and does not spin.
- Stop requests remain responsive while waiting.
Actual
- Recursive installation traverses Resource payloads before the first reconcile.
- Callback filtering does not reduce startup traversal or inotify watch count.
- Resource worktree links can carry traversal outside the declaration namespace.
- Installation failures and timer-only fallback are silent.
- A disconnected watcher channel bypasses the configured timer interval and can continuously reconcile.
Versions
- st2: current
main and 1a8f4009b4c9a85e37cdb035463959494ede8628
- notify: 8.2.0
- OS: Linux with inotify
st2 upcan spend its entire startup traversing Resource payloads before its first reconciliation pass. If watcher installation then fails, the intended timer fallback can also continuously reconcile instead of honoring its interval.Reproduction
https://github.com/schickling-repros/2026-08-st2-notify-recursive-resource-tree
The synthetic catalog has four declaration-space directories and a 512-directory Resource payload. On Linux with
notify8.2.0, recursive registration allocates a watch for the payload directories as well:In the motivating deployment, the inotify descriptor grew past 193,000 watches while registration followed a Resource worktree into an external dependency tree. The first reconcile did not begin for roughly 20 minutes. When recursive installation finally returned/dropped, the watch count fell to four and timer polling reconciled the pending state.
Root cause
Unbounded installation
watch_catalog_declarationsfilters callback paths, but it first callswatcher.watch(root, RecursiveMode::Recursive). The Linux backend eagerly walks the full tree and allocates inotify watches before any callback filtering occurs. Catalog Resource directories may contain arbitrarily large worktrees and dependency trees, including links outside the declaration namespace.Disconnected fallback bypasses the interval
If best-effort watcher installation fails, its channel sender is dropped. The receiver then returns
RecvTimeoutError::Disconnectedimmediately. The old wait loop treats that as the end of the wait and starts another full reconcile, so the nominal timer fallback can become a continuous reconcile loop.The installation error was also discarded behind
Option, making both the loss of immediate wakeups and the cadence failure hard to diagnose.Expected
Actual
Versions
mainand1a8f4009b4c9a85e37cdb035463959494ede8628