OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 commits / 42eae1e

0.1.248: a mirror knew its tombstones and listed the repos anyway
0.1.246 made the tombstone fold survive a mirror restart and 0.1.247 made scan() prune retired names from the listing. Both correct, and on a mirror they never met. Boot order on a MIRROR: 1. pear-git.js foldRetired(repoEventLog) the daemon's OWN log, which on a mirror holds NO tombstones. The set is EMPTY. 2. startSyncService sync.scan() prunes against that empty set, so every retired bare dir stays in sync.repos. 3. RepoPuller._runLoop foldRetired(origin) asynchronously, later, the set finally fills in. 4. nothing re-prunes the map. So the mirror ends up knowing every tombstone and advertising every repo. On an ORIGIN step 1 yields the real set before step 2, which is why this never showed up on tabertron. Measured on stortron 2026-09-02, minutes after 0.1.247 shipped there: `pgh repo retired` returned all 15 names while `pgh repo` still listed 148. A manual `pgh repo scan` dropped it to 133, proving the set was right and only the already-scanned map was stale. startSyncService now exposes pruneRetired, and RepoPuller calls it once the origin fold completes, logging the count. In-memory only -- no disk walk, since the repos dir has not changed and re-walking it is exactly the cost the caller does not need. Idempotent, and a no-op on an origin where the set was already complete before the boot scan. Test is red-first on `typeof sync.pruneRetired === 'function'` and models the real boot order: scan with an empty set, populate the set afterwards, then assert the late-learned tombstone leaves the listing with the watcher stopped so only the explicit hook can satisfy it. 768/771 with cutover-prod-copy excluded. The 3 failures are the same pre-existing set baselined at 0.1.246 (issue-index module-singleton pollution in full-suite runs; release-overlay-repair 2/5). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
42eae1ebf1af02c62c8dff8309aab3a530c64f4b
by plankatron <fleeky@gmail.com> · 2026-09-02T15:17:54.000Z

Changed files (4) +123 -1

lib/services/repo-mirror.js +24 -0
……@@ -212,6 +212,30 @@ export class RepoPuller {
212212 // eslint-disable-next-line no-console
213213 console.error('[repo-mirror] tombstone re-fold incomplete:', err && err.message)
214214 }
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 }
215239 }
216240
217241 // Snapshot the log's live frontier — the index at the moment we
lib/services/sync.js +19 -0
……@@ -139,6 +139,25 @@ export async function startSyncService (opts) {
139139 return repos
140140 }
141141
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
142161 // Populate the in-memory map. We don't call serve() here — the caller
143162 // attaches listeners (repoEventLog's on('serve') hook, etc.) AFTER we
144163 // return, so serve() must run after listener wire-up or the initial
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "pear-git-v3",
3 "version": "0.1.247",
3 "version": "0.1.248",
44 "description": "P2P git forge — multi-writer issue/org/release/peer-mirror daemon. Built bottom-up after the failed v2.",
55 "type": "module",
66 "main": "index.js",
test/retired-mirror-boot-order.test.js +79 -0
……@@ -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
26import test from 'brittle'
27import { mkdtempSync, mkdirSync } from '#node:fs'
28import { join } from '#node:path'
29import { tmpdir } from '#node:os'
30import { execFile } from '../lib/_compat-exec.js'
31
32import { startSyncService } from '../lib/services/sync.js'
33import { RetiredRepos, OP_RETIRED } from '../lib/services/retired-repos.js'
34
35let n = 0
36const tmpDir = () => mkdtempSync(join(tmpdir(), 'pgv3-bootorder-' + (n++) + '-'))
37const listed = (sync) => sync.status().repos.map((r) => r.name).sort()
38
39test('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})