OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 commits / b51e9e1

0.1.247: scan() put every tombstoned repo back in the listing
hyper-sync's scan() does not merge -- it REPLACES `this.repos` with a fresh map built from the repos directory (node_modules/hyper-sync/index.js:101) -- and status() iterates exactly that map. startSyncService pruned after its own boot scan and after each watcher rescan, but pruneRetired is a closure reachable from nowhere else, so the three sites that scan directly put every tombstoned bare dir straight back: services/sync.js 'scan-repos' (pgh repo scan) services/sync.js 'create-repo' pear-git.js:3059 the post-pull rescan Measured on tabertron 2026-09-02: the daemon booted at 09:09, logged "[retired] 15 repo(s) tombstoned", and listed 132 repos. rpc-audit.jsonl shows scan-repos over UDS at 09:15:05, 09:15:14 and 09:20:50; by 09:20 it listed 148. The 15 retired repos were back on `pgh repo` and on the SSR /repos page, which reads list-repos -> sync.status(). scan-repos sits on the ordinary repo-update path, so this reasserted itself continuously rather than once at boot. They stayed UNSERVED throughout (serving: false, topic: null) -- serve() is only reached through paths that do prune -- so this leaked into the listing, not onto the swarm. The page advertised repos the operator had retired. The gate belongs to scan(), not to whoever calls it. Wrapping the instance method once covers all three call sites and any future one, and lets the two now-redundant explicit prunes go. scan() returns `this.repos` itself, so pruning before the return hands every caller the filtered map. Together with 0.1.246 this is what an operator expects from `pgh repo retire`: the origin stops listing it, and the mirror still knows about it tomorrow. Test is red-first: it asserts the boot prune (already green) and then a direct scan() (was ['alpha','beta'], wanted ['beta']). The watcher is stopped before the second assertion so a debounce tick cannot rescue it. Pre-existing failures unchanged from 0.1.246's note. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
b51e9e18b1a781770128b0360f83c5135aa5cf65
by plankatron <fleeky@gmail.com> · 2026-09-02T13:44:48.000Z

Changed files (3) +109 -9

lib/services/sync.js +31 -8
……@@ -114,13 +114,37 @@ export async function startSyncService (opts) {
114114 return pruned
115115 }
116116
117 // scan() populates the in-memory map. We don't call serve() here — the
118 // caller attaches listeners (repoEventLog's on('serve') hook, etc.)
119 // AFTER we return, so serve() must run after listener wire-up or the
120 // initial 'serve' emits get missed. See pear-git.js where serve() is
121 // called explicitly once the sync server is fully wired.
117 // The gate belongs to scan(), not to whoever calls it.
118 //
119 // hyper-sync's scan() does not merge — it REPLACES `this.repos` with a
120 // fresh map built from the repos directory, and status() iterates exactly
121 // that map. Pruning after the boot scan and after each watcher rescan was
122 // therefore not enough: pruneRetired is a closure reachable from nowhere
123 // else, so the three sites that scan directly — the 'scan-repos' and
124 // 'create-repo' RPCs below, and the post-pull rescan in pear-git.js — put
125 // every tombstoned bare dir straight back into the listing. Production,
126 // 2026-09-02: the origin booted with 15 tombstones and listed 132 repos;
127 // two `scan-repos` calls later it listed 148, and the SSR /repos page
128 // advertised repos the operator had retired. (They stayed UNSERVED —
129 // serve() is only reached through paths that prune — so it leaked into
130 // the listing, not onto the swarm.)
131 //
132 // Wrapping the instance method fixes all three call sites and any future
133 // one. scan() returns `this.repos` itself, so pruning before the return
134 // gives every caller the filtered map.
135 const rawScan = sync.scan.bind(sync)
136 sync.scan = async (...args) => {
137 const repos = await rawScan(...args)
138 pruneRetired()
139 return repos
140 }
141
142 // Populate the in-memory map. We don't call serve() here — the caller
143 // attaches listeners (repoEventLog's on('serve') hook, etc.) AFTER we
144 // return, so serve() must run after listener wire-up or the initial
145 // 'serve' emits get missed. See pear-git.js where serve() is called
146 // explicitly once the sync server is fully wired.
122147 await sync.scan().catch(() => {})
123 pruneRetired()
124148
125149 // Watch the repos directory so repos added after boot (new clone, mkdir,
126150 // rename-in) get picked up without a restart. Debounced because a single
……@@ -142,8 +166,7 @@ export async function startSyncService (opts) {
142166 // the destroyed swarm.
143167 if (sync.closing || sync.closed) return
144168 try {
145 await sync.scan()
146 pruneRetired()
169 await sync.scan() // prunes retired names — see the wrapper above
147170 await sync.serve()
148171 } catch (err) {
149172 if (sync.closing || sync.closed) return
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "pear-git-v3",
3 "version": "0.1.246",
3 "version": "0.1.247",
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-repos-scan.test.js +77 -0
……@@ -0,0 +1,77 @@
1/**
2 * A bare `sync.scan()` must not resurrect a tombstoned repo in the LISTING.
3 *
4 * hyper-sync's scan() does not merge — it REPLACES `this.repos` with a fresh
5 * map built from the repos directory (node_modules/hyper-sync/index.js:101),
6 * and status() iterates exactly that map. startSyncService prunes retired
7 * names after its own boot scan and after each watcher rescan, but pruneRetired
8 * is a closure reachable from nowhere else, so the three call sites that scan
9 * directly —
10 *
11 * services/sync.js 'scan-repos' (pgh repo scan)
12 * services/sync.js 'create-repo'
13 * pear-git.js:3059 the post-pull rescan
14 *
15 * — put every tombstoned bare dir straight back into the listing.
16 *
17 * Observed in production 2026-09-02: the origin booted, logged "[retired] 15
18 * repo(s) tombstoned", and listed 132 repos. `scan-repos` ran twice a few
19 * minutes later and the listing became 148 — the 15 retired repos were back
20 * on `pgh repo` and on the SSR /repos page. They stayed unserved (serve() is
21 * only reached via paths that do prune), so this is a listing leak, not an
22 * announcement one: the page advertises repos the operator retired.
23 */
24
25import test from 'brittle'
26import { mkdtempSync, mkdirSync } from '#node:fs'
27import { join } from '#node:path'
28import { tmpdir } from '#node:os'
29import { execFile } from '../lib/_compat-exec.js'
30
31import { startSyncService } from '../lib/services/sync.js'
32import { RetiredRepos, OP_RETIRED } from '../lib/services/retired-repos.js'
33
34let n = 0
35function tmpDir () {
36 return mkdtempSync(join(tmpdir(), 'pgv3-retired-scan-' + (n++) + '-'))
37}
38
39const listed = (sync) => sync.status().repos.map((r) => r.name).sort()
40
41test('scan() does not resurrect a retired repo in the listing', async (t) => {
42 t.timeout(60000)
43
44 const reposDir = join(tmpDir(), 'git-repos')
45 mkdirSync(reposDir, { recursive: true })
46 for (const name of ['alpha', 'beta']) {
47 await execFile('git', ['init', '--bare', '-q', join(reposDir, name + '.git')])
48 }
49
50 const retired = new RetiredRepos()
51 retired.applyEntry({ op: OP_RETIRED, name: 'alpha' }, 0)
52
53 // offline: no Hyperswarm, no DHT. scan/status/prune are unaffected by it.
54 const sync = await startSyncService({
55 reposDir,
56 offline: true,
57 isRetired: retired.isRetired
58 })
59 t.teardown(async () => {
60 try { sync._reposWatcher.cancelRescan() } catch {}
61 try { sync._reposWatcher.close() } catch {}
62 try { await sync.close() } catch {}
63 })
64
65 t.alike(listed(sync), ['beta'], 'boot: the tombstoned repo is not listed')
66
67 // The watcher prunes; the point of this test is the callers that don't.
68 // Stop it so the assertion below cannot be rescued by a debounce tick.
69 sync._reposWatcher.cancelRescan()
70 sync._reposWatcher.close()
71
72 // Exactly what 'scan-repos' / 'create-repo' / the post-pull rescan do.
73 await sync.scan()
74
75 t.alike(listed(sync), ['beta'],
76 'after a direct scan(): still not listed, and live repos survive')
77})