OverviewFilesCommitsIssuesDocsReleasesPeers

git-remote-punch / lib / durable-connect.js

113 lines · javascript ref:
/**
 * durableConnect — event-driven, no-aggressive-deadline connect over an
 * injected Hyperswarm.
 *
 * Replaces the fragile "swarm.once('connection') + setTimeout(15000, reject)"
 * model that this transport used to use. That model treats a P2P connection as
 * a synchronous timed dial and gives up when the stopwatch wins — which is what
 * starved concurrent org-repo pulls: N `git fetch punch://` each spin their own
 * swarm, compete for DHT/hole-punch budget, and the losers of that race hit the
 * 15s deadline and fail with ZERO objects (observed on ashatron 2026-07-18:
 * repos that started in the same burst all failed empty; less-contended ones
 * completed). See pear-git org issue #3.
 *
 * Instead we treat a connection as an event-driven resource — matching how keet
 * and hyper-overlay stay durable:
 *
 *   - join(topic, {client:true}) once, then swarm.on('connection') — react to
 *     EVERY connection as it arrives, and STAY joined so hyperswarm keeps
 *     re-forming connections across peers while the topic is joined.
 *   - NO wall-clock connect deadline racing the DHT+hole-punch. A connection
 *     that takes 2s or 40s under contention is fine — it just waits.
 *   - Reconcile ACROSS connections: resolve on the FIRST connection that passes
 *     `validate(conn, info)`; a connection that fails validation is discarded
 *     and the next is taken (never first-wins-fail).
 *   - Cancellable via an AbortSignal AND, for callers that have a fallback path
 *     (git-remote-punch has an HTTPS fallback), an OPTIONAL generous
 *     `fallbackAfter` no-progress bound. This is a last-resort escape so an
 *     offline server doesn't hang forever — NOT a connect deadline. It must be
 *     long enough to never race a live hole-punch under load; the aggressive
 *     15s was the bug, a 120s escape is not.
 *
 * The swarm is INJECTED and never created/destroyed here — the caller owns its
 * lifetime (for the git-remote-punch helper that is one process; for a daemon
 * it is a long-lived shared swarm).
 *
 * @param {object} swarm    - a joined-capable Hyperswarm (has join + on/off)
 * @param {Buffer} topic    - 32-byte swarm topic
 * @param {object} [opts]
 * @param {(conn, info) => (boolean|Promise<boolean>)} [opts.validate] - liveness
 *        predicate; default accepts the first connection.
 * @param {AbortSignal} [opts.signal]       - caller cancellation.
 * @param {number} [opts.fallbackAfter]     - ms; if > 0, reject after this long
 *        with NO validated connection so the caller can fall back. 0/undefined =
 *        wait indefinitely (the bound belongs to the caller).
 * @returns {Promise<object>} the first connection that passes validate().
 */
export function durableConnect (swarm, topic, opts = {}) {
  const validate = opts.validate || (() => true)
  const signal = opts.signal
  const fallbackAfter = Number(opts.fallbackAfter) || 0

  return new Promise((resolve, reject) => {
    let settled = false
    let fallbackTimer = null

    const cleanup = () => {
      swarm.off('connection', onConnection)
      if (fallbackTimer) clearTimeout(fallbackTimer)
      if (signal) { try { signal.removeEventListener('abort', onAbort) } catch {} }
    }

    const onConnection = (conn, info) => {
      if (settled) return
      Promise.resolve(validate(conn, info))
        .then((ok) => {
          if (settled) return
          if (ok) {
            settled = true
            cleanup()
            resolve(conn)
          } else {
            // Stale/dead/wrong peer — discard and keep waiting. hyperswarm
            // keeps forming connections to other (live) peers while joined.
            try { conn.destroy() } catch {}
          }
        })
        .catch(() => { try { conn.destroy() } catch {} })
    }

    const onAbort = () => {
      if (settled) return
      settled = true
      cleanup()
      reject(new Error('durable connect aborted'))
    }

    if (signal) {
      if (signal.aborted) return onAbort()
      signal.addEventListener('abort', onAbort)
    }

    // Generous no-progress escape (NOT a connect race) — only for callers that
    // opt in because they have a fallback (e.g. HTTPS). Waits by default.
    if (fallbackAfter > 0) {
      fallbackTimer = setTimeout(() => {
        if (settled) return
        settled = true
        cleanup()
        reject(new Error('durable connect: no peer reached after ' + fallbackAfter + 'ms'))
      }, fallbackAfter)
      // NOT unref'd: this bound MUST fire even when nothing else keeps the loop
      // alive, so an offline server falls back instead of hanging. cleanup()
      // clears it the moment a connection validates or the caller aborts.
    }

    // on(), not once() — react to EVERY connection until one validates.
    swarm.on('connection', onConnection)
    swarm.join(topic, { server: false, client: true })
  })
}

export default durableConnect