OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-sync commits / a045cbc

pull: auto-reclone stuck repos (0 refs + tmp_packs)
A repo with 0 refs and one or more tmp_pack_* files is wedged — a prior transfer got interrupted mid-pack, nothing finalized, and git fetch against that state typically can't recover (the transport doesn't know how to build on the partial pack bytes). Previously the user had to `rm -rf` the bare repo by hand. Now: detect the stuck state at the start of each pull, nuke + re- init the bare repo, and proceed with a fresh fetch. Also add a last-resort re-init + fetch if the 3-attempt retry loop leaves the repo stuck. Covers the "Class B" failure mode observed on stortron (6 tmp_packs, 0 refs on pear-git-v3.git, 37 on hyper-overlay.git). Emits 'force-reclone' lifecycle event so callers can see what the sync did. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
a045cbc6306fb86aa1df53a74b8527975c6d92ae
by plankatron <fleeky@gmail.com> · 2026-04-21T19:57:58.000Z

Changed files (1) +76 -27

index.js +76 -27
……@@ -193,6 +193,8 @@ module.exports = class HyperSync extends ReadyResource {
193193
194194 for (const name of repos) {
195195 const repoPath = path.join(this._reposDir, name + '.git');
196 const packDir = path.join(repoPath, 'objects', 'pack');
197 const fetchUrl = 'punch://' + remote.serverKey + '/' + name;
196198
197199 // Ensure repo exists locally
198200 if (!fs.existsSync(repoPath)) {
……@@ -204,30 +206,34 @@ module.exports = class HyperSync extends ReadyResource {
204206 }
205207 }
206208
207 // Pre-fetch cleanup: git-receive-pack (and git-fetch in the
208 // remote-punch transport) writes incoming data to
209 // `objects/pack/tmp_pack_XXX` and only renames to a finalized
210 // `pack-<sha>.pack` on success. An interrupted transfer leaves
211 // the tmp file orphaned; these accumulate indefinitely (we've
212 // seen 37 on hyper-overlay) and waste disk + slow operations.
213 // Clean them before starting each fetch so the state is
214 // deterministic and retries aren't contaminated by stale data.
215 const packDir = path.join(repoPath, 'objects', 'pack');
216 try {
217 for (const f of fs.readdirSync(packDir)) {
218 if (f.startsWith('tmp_pack_')) {
219 try { fs.unlinkSync(path.join(packDir, f)) } catch {}
220 }
209 // Detect stuck state: repo has tmp_packs from prior aborted
210 // attempts and no refs materialized. Force-reclone by nuking and
211 // reinitting — next attempt starts from a clean slate without
212 // the user having to rm -rf by hand.
213 const wasStuck = this._isStuck(repoPath, packDir);
214 if (wasStuck) {
215 fs.rmSync(repoPath, { recursive: true, force: true });
216 try {
217 await this._exec('git', ['init', '--bare', repoPath]);
218 this.emit('pull', remoteName, name, 'force-reclone');
219 } catch (err) {
220 result.failed.push({ name, error: 'force-reclone init failed: ' + err.message });
221 continue;
221222 }
222 } catch {}
223 }
223224
224 const fetchUrl = 'punch://' + remote.serverKey + '/' + name;
225 // Pre-fetch cleanup: an interrupted transfer leaves
226 // `objects/pack/tmp_pack_XXX` orphaned; these accumulate
227 // indefinitely (seen 37 on hyper-overlay) and slow every
228 // subsequent operation. Clean before each attempt so state is
229 // deterministic.
230 this._cleanTmpPacks(packDir);
225231
226232 this.emit('pull', remoteName, name, 'start');
227233
228 // Retry on transient failures. The punch:// transport is stateless
229 // per-attempt — pre-fetch cleanup above gives each retry a clean
230 // slate. Exponential backoff bounds total time.
234 // Retry on transient failures. Bounded attempts + exponential
235 // backoff + per-attempt tmp_pack cleanup so a failure's garbage
236 // can't contaminate the next try.
231237 const maxAttempts = 3;
232238 let lastErr = null;
233239 let synced = false;
……@@ -239,14 +245,7 @@ module.exports = class HyperSync extends ReadyResource {
239245 } catch (err) {
240246 lastErr = err;
241247 if (attempt < maxAttempts) {
242 // Re-clean tmp_packs left by this failed attempt before retry
243 try {
244 for (const f of fs.readdirSync(packDir)) {
245 if (f.startsWith('tmp_pack_')) {
246 try { fs.unlinkSync(path.join(packDir, f)) } catch {}
247 }
248 }
249 } catch {}
248 this._cleanTmpPacks(packDir);
250249 await new Promise(r => setTimeout(r, 1000 * attempt));
251250 }
252251 }
……@@ -256,6 +255,24 @@ module.exports = class HyperSync extends ReadyResource {
256255 result.synced.push(name);
257256 this.emit('pull', remoteName, name, 'complete');
258257 } else {
258 // One last resort: if we arrived stuck or got stuck, wipe
259 // and try one more fresh clone. This catches repos where the
260 // on-disk state is wedged in a way git fetch can't recover
261 // from.
262 if (!wasStuck && this._isStuck(repoPath, packDir)) {
263 fs.rmSync(repoPath, { recursive: true, force: true });
264 try {
265 await this._exec('git', ['init', '--bare', repoPath]);
266 this.emit('pull', remoteName, name, 'force-reclone');
267 await this._gitFetch(repoPath, fetchUrl);
268 synced = true;
269 result.synced.push(name);
270 this.emit('pull', remoteName, name, 'complete');
271 continue;
272 } catch (err) {
273 lastErr = err;
274 }
275 }
259276 result.failed.push({ name, error: lastErr ? lastErr.message : 'unknown error', attempts: maxAttempts });
260277 this.emit('pull', remoteName, name, 'failed');
261278 }
……@@ -265,6 +282,38 @@ module.exports = class HyperSync extends ReadyResource {
265282 return result;
266283 }
267284
285 // ── Helpers for pull recovery ─────────────────────────────
286
287 _cleanTmpPacks (packDir) {
288 try {
289 for (const f of fs.readdirSync(packDir)) {
290 if (f.startsWith('tmp_pack_')) {
291 try { fs.unlinkSync(path.join(packDir, f)) } catch {}
292 }
293 }
294 } catch {}
295 }
296
297 _isStuck (repoPath, packDir) {
298 if (!fs.existsSync(repoPath)) return false;
299 // "Stuck" = no refs materialized AND at least one tmp_pack sits
300 // in objects/pack/. Either symptom alone might be benign (a fresh
301 // init has no refs; a successful fetch has no tmp_packs) — the
302 // combination indicates a prior transfer received bytes but never
303 // finalized, and plain retry via fetch rarely recovers it.
304 let refsCount = 0;
305 try {
306 refsCount = fs.readdirSync(path.join(repoPath, 'refs', 'heads')).length;
307 } catch {}
308 if (refsCount > 0) return false;
309 try {
310 for (const f of fs.readdirSync(packDir)) {
311 if (f.startsWith('tmp_pack_')) return true;
312 }
313 } catch {}
314 return false;
315 }
316
268317 // ── Connection Handler ────────────────────────────────────
269318
270319 _onConnection (conn, info) {