| … | … | @@ -212,6 +212,30 @@ export class RepoPuller { |
| 212 | 212 | // eslint-disable-next-line no-console |
| 213 | 213 | console.error('[repo-mirror] tombstone re-fold incomplete:', err && err.message) |
| 214 | 214 | } |
| 215 | ||
| 216 | // Re-prune the sync map now that the set is populated. | |
| 217 | // | |
| 218 | // The fold above is the FIRST time a mirror learns its tombstones — its | |
| 219 | // own RepoEventLog carries none, so the set was empty when the sync | |
| 220 | // service ran its boot scan and pruned against it. Everything retired is | |
| 221 | // therefore still sitting in sync.repos, and status() (so `pgh repo` and | |
| 222 | // the SSR /repos page) lists it. Without this the mirror knows the | |
| 223 | // tombstones and advertises the repos anyway. | |
| 224 | // | |
| 225 | // In-memory prune, no disk walk. Idempotent, and a no-op on an origin, | |
| 226 | // where the set was already complete before the boot scan. | |
| 227 | if (this._retired.size > 0 && this._sync && typeof this._sync.pruneRetired === 'function') { | |
| 228 | try { | |
| 229 | const pruned = this._sync.pruneRetired() | |
| 230 | if (pruned > 0) { | |
| 231 | // eslint-disable-next-line no-console | |
| 232 | console.log('[repo-mirror] pruned ' + pruned + ' retired repo(s) from the listing after folding the origin log') | |
| 233 | } | |
| 234 | } catch (err) { | |
| 235 | // eslint-disable-next-line no-console | |
| 236 | console.error('[repo-mirror] post-fold prune failed:', err && err.message) | |
| 237 | } | |
| 238 | } | |
| 215 | 239 | } |
| 216 | 240 | |
| 217 | 241 | // Snapshot the log's live frontier — the index at the moment we |
| … | … | @@ -139,6 +139,25 @@ export async function startSyncService (opts) { |
| 139 | 139 | return repos |
| 140 | 140 | } |
| 141 | 141 | |
| 142 | // Exposed for the case where the tombstone set is populated AFTER this | |
| 143 | // service has already scanned. | |
| 144 | // | |
| 145 | // On an ORIGIN that cannot happen: pear-git.js folds the daemon's own | |
| 146 | // RepoEventLog before starting us, so the set is complete by the boot scan. | |
| 147 | // On a MIRROR it is the normal case — the mirror's own log carries no | |
| 148 | // tombstones, so the set is EMPTY here, and it only fills in later when | |
| 149 | // RepoPuller folds the ORIGIN's log. Without a way to re-prune, the mirror | |
| 150 | // ends up knowing the tombstones while still listing the repos. | |
| 151 | // | |
| 152 | // (stortron, 2026-09-02, right after 0.1.247 shipped: `pgh repo retired` | |
| 153 | // returned all 15 names while `pgh repo` still listed 148. A manual | |
| 154 | // `pgh repo scan` dropped it to 133 — the set was right, only the | |
| 155 | // already-scanned map was stale.) | |
| 156 | // | |
| 157 | // This prunes the in-memory map only; it does NOT re-walk the repos dir, so | |
| 158 | // the caller pays nothing for the disk scan it does not need. | |
| 159 | sync.pruneRetired = pruneRetired | |
| 160 | ||
| 142 | 161 | // Populate the in-memory map. We don't call serve() here — the caller |
| 143 | 162 | // attaches listeners (repoEventLog's on('serve') hook, etc.) AFTER we |
| 144 | 163 | // return, so serve() must run after listener wire-up or the initial |
| … | … | @@ -1,6 +1,6 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "pear-git-v3", |
| 3 | "version": "0.1.247", | |
| 3 | "version": "0.1.248", | |
| 4 | 4 | "description": "P2P git forge — multi-writer issue/org/release/peer-mirror daemon. Built bottom-up after the failed v2.", |
| 5 | 5 | "type": "module", |
| 6 | 6 | "main": "index.js", |
| … | … | @@ -0,0 +1,79 @@ |
| 1 | /** | |
| 2 | * A mirror learns its tombstones AFTER the boot scan has already run. | |
| 3 | * | |
| 4 | * Boot order on a MIRROR: | |
| 5 | * | |
| 6 | * 1. pear-git.js foldRetired(repoEventLog) -- the daemon's OWN log, which | |
| 7 | * on a mirror holds NO | |
| 8 | * tombstones. Set is empty. | |
| 9 | * 2. startSyncService sync.scan() -- prunes against that EMPTY | |
| 10 | * set, so every retired bare | |
| 11 | * dir stays in sync.repos. | |
| 12 | * 3. RepoPuller._runLoop foldRetired(origin) -- asynchronously, later, the | |
| 13 | * set becomes populated. | |
| 14 | * 4. ...nothing re-prunes the map. | |
| 15 | * | |
| 16 | * So the mirror knows the tombstones and still lists the repos. Measured on | |
| 17 | * stortron 2026-09-02 right after deploying 0.1.247: `pgh repo retired` | |
| 18 | * returned all 15 names while `pgh repo` still listed 148; a manual | |
| 19 | * `pgh repo scan` dropped it to 133, confirming the set was right and only the | |
| 20 | * already-scanned map was stale. | |
| 21 | * | |
| 22 | * On an ORIGIN step 1 yields the real set before step 2, which is why this | |
| 23 | * never showed up there. | |
| 24 | */ | |
| 25 | ||
| 26 | import test from 'brittle' | |
| 27 | import { mkdtempSync, mkdirSync } from '#node:fs' | |
| 28 | import { join } from '#node:path' | |
| 29 | import { tmpdir } from '#node:os' | |
| 30 | import { execFile } from '../lib/_compat-exec.js' | |
| 31 | ||
| 32 | import { startSyncService } from '../lib/services/sync.js' | |
| 33 | import { RetiredRepos, OP_RETIRED } from '../lib/services/retired-repos.js' | |
| 34 | ||
| 35 | let n = 0 | |
| 36 | const tmpDir = () => mkdtempSync(join(tmpdir(), 'pgv3-bootorder-' + (n++) + '-')) | |
| 37 | const listed = (sync) => sync.status().repos.map((r) => r.name).sort() | |
| 38 | ||
| 39 | test('a tombstone learned AFTER the boot scan still drops out of the listing', async (t) => { | |
| 40 | t.timeout(60000) | |
| 41 | ||
| 42 | const reposDir = join(tmpDir(), 'git-repos') | |
| 43 | mkdirSync(reposDir, { recursive: true }) | |
| 44 | for (const name of ['alpha', 'beta']) { | |
| 45 | await execFile('git', ['init', '--bare', '-q', join(reposDir, name + '.git')]) | |
| 46 | } | |
| 47 | ||
| 48 | // Mirror boot: the set is EMPTY when the sync service scans. | |
| 49 | const retired = new RetiredRepos() | |
| 50 | ||
| 51 | const sync = await startSyncService({ | |
| 52 | reposDir, | |
| 53 | offline: true, | |
| 54 | isRetired: retired.isRetired | |
| 55 | }) | |
| 56 | t.teardown(async () => { | |
| 57 | try { sync._reposWatcher.cancelRescan() } catch {} | |
| 58 | try { sync._reposWatcher.close() } catch {} | |
| 59 | try { await sync.close() } catch {} | |
| 60 | }) | |
| 61 | ||
| 62 | t.alike(listed(sync), ['alpha', 'beta'], 'boot scan lists both — nothing retired yet') | |
| 63 | ||
| 64 | // The RepoPuller's fold of the ORIGIN's log lands, populating the shared set. | |
| 65 | retired.applyEntry({ op: OP_RETIRED, name: 'alpha' }, 0) | |
| 66 | t.ok(retired.has('alpha'), 'the set now knows the tombstone') | |
| 67 | ||
| 68 | // Stop the watcher so only the explicit hook can rescue the assertion. | |
| 69 | sync._reposWatcher.cancelRescan() | |
| 70 | sync._reposWatcher.close() | |
| 71 | ||
| 72 | t.is(typeof sync.pruneRetired, 'function', | |
| 73 | 'the sync service exposes pruneRetired so a late fold can re-prune') | |
| 74 | ||
| 75 | sync.pruneRetired() | |
| 76 | ||
| 77 | t.alike(listed(sync), ['beta'], | |
| 78 | 'the late-learned tombstone drops out without re-walking the repos dir') | |
| 79 | }) |