/**
* Regression: the EVENT-DRIVEN per-repo reconcile must coalesce and must hand
* the event loop back.
*
* `reconcileAll` (the periodic sweep) got a macrotask yield in 0.1.180 because
* running N repos back-to-back starved the daemon for the whole sweep. The
* event-driven path never got the same treatment, and its justification in
* ref-reconciler-manager.js was that per-repo reconcile is "already
* event-driven ... so latency here is free". That holds on an ORIGIN, whose
* trackers only advance when someone pushes.
*
* It does NOT hold on a MIRROR. There every tracker is a replication target, so
* during initial sync (or a re-drain after an unclean stop) all N trackers
* advance continuously. Each reconcile costs a `listRefs()` — `await
* base.update()` plus a full ref range-scan — and undebounced that re-runs per
* replication chunk across every repo. Measured on stortron: a full core burned
* indefinitely, HTTP never got an accept(), and exited git children were never
* reaped (SIGCHLD needs the macrotask phase).
*
* `await` alone cannot fix this: awaiting an already-resolved promise drains the
* MICROtask queue only. The debounce timer is the macrotask handoff.
*/
import test from 'brittle'
import { RefReconciler } from '../lib/services/ref-reconciler.js'
// Minimal tracker stand-in: the reconcile path needs `base.on/off` plus
// `listRefs()`. With no refs and no `getMeta`, `_reconcileOnce` touches no git
// and no disk, so these tests exercise scheduling only.
function makeFakeTracker () {
const listeners = []
return {
base: {
on (ev, fn) { if (ev === 'update') listeners.push(fn) },
off (ev, fn) {
const i = listeners.indexOf(fn)
if (i !== -1) listeners.splice(i, 1)
},
emit () { for (const fn of listeners.slice()) fn() }
},
listenerCount: () => listeners.length,
async listRefs () { return [] }
}
}
test('start() reconciles immediately — the debounce never delays first materialization', async (t) => {
const tracker = makeFakeTracker()
const rec = new RefReconciler({ tracker, repoPath: '/nonexistent', debounceMs: 1000 })
let passes = 0
rec._reconcileOnce = async () => { passes++ }
await rec.start()
t.is(passes, 1, 'first pass ran synchronously with start(), not after debounceMs')
await rec.stop()
})
test('a burst of tracker updates coalesces into ONE reconcile', async (t) => {
const tracker = makeFakeTracker()
const rec = new RefReconciler({ tracker, repoPath: '/nonexistent', debounceMs: 30 })
let passes = 0
rec._reconcileOnce = async () => { passes++ }
await rec.start()
t.is(passes, 1, 'baseline: start() pass')
// A replication burst: what a mirror sees while a tracker catches up.
for (let i = 0; i < 50; i++) tracker.base.emit('update')
t.is(passes, 1, 'no reconcile fires synchronously from the update event')
await new Promise((resolve) => setTimeout(resolve, 90))
t.is(passes, 2, '50 updates collapsed into exactly one additional pass')
await rec.stop()
})
test('event-driven reconciles run as macrotasks, so timers still fire', async (t) => {
const tracker = makeFakeTracker()
const rec = new RefReconciler({ tracker, repoPath: '/nonexistent', debounceMs: 5 })
// Each pass burns CPU synchronously, the way a real reconcile does.
rec._reconcileOnce = async () => {
const until = Date.now() + 3
while (Date.now() < until) { /* spin */ }
}
await rec.start()
let ticks = 0
const iv = setInterval(() => { ticks++ }, 1)
t.teardown(() => clearInterval(iv))
for (let i = 0; i < 15; i++) {
tracker.base.emit('update')
await new Promise((resolve) => setTimeout(resolve, 7))
}
// 0 ticks would mean the loop never came back to the timer queue.
t.ok(ticks > 0, 'timers ran between event-driven reconciles (loop not starved)')
await rec.stop()
})
test('stop() cancels a pending debounced pass and detaches the listener', async (t) => {
const tracker = makeFakeTracker()
const rec = new RefReconciler({ tracker, repoPath: '/nonexistent', debounceMs: 25 })
let passes = 0
rec._reconcileOnce = async () => { passes++ }
await rec.start()
t.is(passes, 1)
tracker.base.emit('update') // schedules a pass
await rec.stop() // must cancel it
await new Promise((resolve) => setTimeout(resolve, 60))
t.is(passes, 1, 'no reconcile fired against a tracker the caller is closing')
t.is(tracker.listenerCount(), 0, 'update listener detached')
})