| … | … | @@ -114,13 +114,37 @@ export async function startSyncService (opts) { |
| 114 | 114 | return pruned |
| 115 | 115 | } |
| 116 | 116 | |
| 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. | |
| 122 | 147 | await sync.scan().catch(() => {}) |
| 123 | pruneRetired() | |
| 124 | 148 | |
| 125 | 149 | // Watch the repos directory so repos added after boot (new clone, mkdir, |
| 126 | 150 | // rename-in) get picked up without a restart. Debounced because a single |
| … | … | @@ -142,8 +166,7 @@ export async function startSyncService (opts) { |
| 142 | 166 | // the destroyed swarm. |
| 143 | 167 | if (sync.closing || sync.closed) return |
| 144 | 168 | try { |
| 145 | await sync.scan() | |
| 146 | pruneRetired() | |
| 169 | await sync.scan() // prunes retired names — see the wrapper above | |
| 147 | 170 | await sync.serve() |
| 148 | 171 | } catch (err) { |
| 149 | 172 | if (sync.closing || sync.closed) return |
| … | … | @@ -1,6 +1,6 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "pear-git-v3", |
| 3 | "version": "0.1.246", | |
| 3 | "version": "0.1.247", | |
| 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,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 | ||
| 25 | import test from 'brittle' | |
| 26 | import { mkdtempSync, mkdirSync } from '#node:fs' | |
| 27 | import { join } from '#node:path' | |
| 28 | import { tmpdir } from '#node:os' | |
| 29 | import { execFile } from '../lib/_compat-exec.js' | |
| 30 | ||
| 31 | import { startSyncService } from '../lib/services/sync.js' | |
| 32 | import { RetiredRepos, OP_RETIRED } from '../lib/services/retired-repos.js' | |
| 33 | ||
| 34 | let n = 0 | |
| 35 | function tmpDir () { | |
| 36 | return mkdtempSync(join(tmpdir(), 'pgv3-retired-scan-' + (n++) + '-')) | |
| 37 | } | |
| 38 | ||
| 39 | const listed = (sync) => sync.status().repos.map((r) => r.name).sort() | |
| 40 | ||
| 41 | test('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 | }) |