| … | … | @@ -11,6 +11,12 @@ import { resolveUserId } from '../auth/user-resolver.js' |
| 11 | 11 | import { mountOrgGroupedRepos } from '../services/org-grouped-repos.js' |
| 12 | 12 | import { assertValidRepoName } from '../repo-name.js' |
| 13 | 13 | |
| 14 | // Debounce for the org-room re-sweep. Matches attachSshResyncTrigger and |
| 15 | // scheduleMirrorReannounce so every autobase-update-driven path in the daemon |
| 16 | // coalesces on the same interval. See autoMountOrgRepos for why this must be a |
| 17 | // timer (macrotask) and not a bare await. |
| 18 | const ORG_RESWEEP_DEBOUNCE_MS = 1500 |
| 19 | |
| 14 | 20 | export function orgHandlers (svc) { |
| 15 | 21 | const orgs = () => svc.modules.orgs |
| 16 | 22 | const registry = () => svc.identityRegistry |
| … | … | @@ -130,7 +136,52 @@ export function orgHandlers (svc) { |
| 130 | 136 | const room = orgs().getOrgRoom(orgName) |
| 131 | 137 | if (room && room.base && typeof room.base.on === 'function' && !svc._orgRepoWatchers.has(orgName)) { |
| 132 | 138 | svc._orgRepoWatchers.add(orgName) |
| 133 | | const onUpdate = () => { mountOrgGroupedRepos(orgName, deps).catch(() => {}) } |
| 139 | |
| 140 | // DEBOUNCED + SERIALIZED. The previous handler was fire-and-forget, so |
| 141 | // every org-room view advance started ANOTHER full sweep of that org's |
| 142 | // repos while earlier sweeps were still running. `mounted` dedups the |
| 143 | // mount ACTION, but not the sweep: each pass still walks the org's repo |
| 144 | // list calling ensureFor(), which opens a per-repo tracker autobase and |
| 145 | // runs a reconcile. |
| 146 | // |
| 147 | // On an ORIGIN this is harmless — org rooms only advance when someone |
| 148 | // writes. On a MIRROR every org room is a replication target, so during |
| 149 | // catch-up all 11 advance continuously and sweeps stack without bound. |
| 150 | // Measured on stortron: 294 org-mount lines in one boot against a normal |
| 151 | // 117, with the daemon pinned at 100% CPU and boot never reaching its |
| 152 | // banner. |
| 153 | // |
| 154 | // setTimeout is deliberate, not cosmetic: it is a MACROTASK, so the loop |
| 155 | // is handed back between sweeps. Awaiting alone would only drain the |
| 156 | // microtask queue and still starve accept()/SIGCHLD — the same trap |
| 157 | // documented in 0.1.180, 0.1.214 and 0.1.215. |
| 158 | let sweepTimer = null |
| 159 | let sweeping = false |
| 160 | let resweep = false |
| 161 | |
| 162 | const runSweep = async () => { |
| 163 | if (sweeping) { resweep = true; return } // coalesce into the running pass |
| 164 | sweeping = true |
| 165 | try { |
| 166 | do { |
| 167 | resweep = false |
| 168 | await mountOrgGroupedRepos(orgName, deps) |
| 169 | } while (resweep) |
| 170 | } catch { |
| 171 | } finally { |
| 172 | sweeping = false |
| 173 | } |
| 174 | } |
| 175 | |
| 176 | const onUpdate = () => { |
| 177 | if (sweepTimer) return // a sweep is already scheduled |
| 178 | sweepTimer = setTimeout(() => { |
| 179 | sweepTimer = null |
| 180 | runSweep() |
| 181 | }, ORG_RESWEEP_DEBOUNCE_MS) |
| 182 | if (sweepTimer && typeof sweepTimer.unref === 'function') sweepTimer.unref() |
| 183 | } |
| 184 | |
| 134 | 185 | room.base.on('update', onUpdate) |
| 135 | 186 | } |
| 136 | 187 | await mountOrgGroupedRepos(orgName, deps) |