| … | … | @@ -193,6 +193,8 @@ module.exports = class HyperSync extends ReadyResource { |
| 193 | 193 | |
| 194 | 194 | for (const name of repos) { |
| 195 | 195 | const repoPath = path.join(this._reposDir, name + '.git'); |
| 196 | const packDir = path.join(repoPath, 'objects', 'pack'); |
| 197 | const fetchUrl = 'punch://' + remote.serverKey + '/' + name; |
| 196 | 198 | |
| 197 | 199 | // Ensure repo exists locally |
| 198 | 200 | if (!fs.existsSync(repoPath)) { |
| … | … | @@ -204,30 +206,34 @@ module.exports = class HyperSync extends ReadyResource { |
| 204 | 206 | } |
| 205 | 207 | } |
| 206 | 208 | |
| 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; |
| 221 | 222 | } |
| 222 | | } catch {} |
| 223 | } |
| 223 | 224 | |
| 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); |
| 225 | 231 | |
| 226 | 232 | this.emit('pull', remoteName, name, 'start'); |
| 227 | 233 | |
| 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. |
| 231 | 237 | const maxAttempts = 3; |
| 232 | 238 | let lastErr = null; |
| 233 | 239 | let synced = false; |
| … | … | @@ -239,14 +245,7 @@ module.exports = class HyperSync extends ReadyResource { |
| 239 | 245 | } catch (err) { |
| 240 | 246 | lastErr = err; |
| 241 | 247 | 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); |
| 250 | 249 | await new Promise(r => setTimeout(r, 1000 * attempt)); |
| 251 | 250 | } |
| 252 | 251 | } |
| … | … | @@ -256,6 +255,24 @@ module.exports = class HyperSync extends ReadyResource { |
| 256 | 255 | result.synced.push(name); |
| 257 | 256 | this.emit('pull', remoteName, name, 'complete'); |
| 258 | 257 | } 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 | } |
| 259 | 276 | result.failed.push({ name, error: lastErr ? lastErr.message : 'unknown error', attempts: maxAttempts }); |
| 260 | 277 | this.emit('pull', remoteName, name, 'failed'); |
| 261 | 278 | } |
| … | … | @@ -265,6 +282,38 @@ module.exports = class HyperSync extends ReadyResource { |
| 265 | 282 | return result; |
| 266 | 283 | } |
| 267 | 284 | |
| 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 | |
| 268 | 317 | // ── Connection Handler ──────────────────────────────────── |
| 269 | 318 | |
| 270 | 319 | _onConnection (conn, info) { |