Skip to content
12 changes: 10 additions & 2 deletions sources/builtin.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,21 @@ func NewBuiltInSource(dir fs.FS) *BuiltInSource {
}

func (s *BuiltInSource) Sync(_ *ui.UI, _ bool) (bool, error) {
return true, nil
// This source performs no actual synchronisation, so "false" is the
// correct answer to "did I actually update anything?" -- returning
// "true" here poisons Sources.isSynchronised (sources.go), which is set
// if *any* source reports it synced, and since BuiltInSource is always
// prepended, that made every other source's "sync and retry" a no-op.
return false, nil
}

func (s *BuiltInSource) URI() string {
return "builtin:///"
}

func (s *BuiltInSource) Bundle() fs.FS {
return &uriFS{s.URI(), s.fs}
// dir is deliberately left empty: this source is backed by an in-memory
// FS with no directory on disk that could vanish out from under it (see
// the comment on uriFS.dir).
return &uriFS{uri: s.URI(), FS: s.fs}
}
257 changes: 218 additions & 39 deletions sources/git.go
Original file line number Diff line number Diff line change
@@ -1,57 +1,130 @@
package sources

import (
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
"time"

"github.com/cashapp/hermit/errors"
"github.com/cashapp/hermit/ui"
"github.com/cashapp/hermit/util"
)

// fsTimeGranularity is the coarsest mtime resolution we expect from the
// filesystems Hermit runs on (eg. HFS+ stores whole seconds), used as slack
// when comparing timestamps taken before and after acquiring the sync lock.
const fsTimeGranularity = time.Second

// Suffixes/prefixes used for Hermit scratch state living alongside source
// directories. A real source directory name is a bare hex SHA256 hash (see
// util.Hash), so anything containing these is unambiguously scratch state.
const (
tmpInfix = ".tmp-" // in-progress clone: <hash>.tmp-XXXXXXXX
asideSuffix = util.DirSwapAsideSuffix // previous tree, mid-swap, pending deletion
legacyTmpInfix = "-" // clone temp dirs created by older Hermit versions: <hash>-XXXXXXXX
staleScratchAge = 24 * time.Hour
)

// GitSource is a new Source based on a git repo
type GitSource struct {
fs *uriFS
sourceDir string
path string
runner util.CommandRunner
fs *uriFS
sourceDir string
path string
runner util.CommandRunner
lockTimeout time.Duration
}

// NewGitSource returns a new GitSource
func NewGitSource(uri, sourceDir string, runner util.CommandRunner) *GitSource {
return NewGitSourceWithLockTimeout(uri, sourceDir, runner, DefaultLockTimeout)
}

// NewGitSourceWithLockTimeout returns a new GitSource with an explicit
// timeout for the lock acquired around synchronisation. This is the
// underlying constructor NewGitSource itself uses to apply
// DefaultLockTimeout; tests use it directly to exercise timeout/contention
// behaviour without waiting out the real default.
//
// A timeout <= 0 is treated as DefaultLockTimeout.
func NewGitSourceWithLockTimeout(uri, sourceDir string, runner util.CommandRunner, lockTimeout time.Duration) *GitSource {
key := util.Hash(uri)
path := filepath.Join(sourceDir, key)
return &GitSource{&uriFS{
uri: uri,
FS: os.DirFS(path),
}, sourceDir, path, runner}
}, sourceDir, path, runner, lockTimeout}
}

func (s *GitSource) Sync(p *ui.UI, force bool) (bool, error) {
info, _ := os.Stat(s.path)
task := p.Task(s.fs.uri)
if info == nil || force || time.Since(info.ModTime()) >= SyncFrequency {
err := s.ensureSourcesDirExists()
if err != nil {
return false, errors.WithStack(err)
}

err = syncGit(task, s.sourceDir, s.fs.uri, s.path, s.runner)
// If the sync failed while the repo had already been cloned, log a warning
// If the repo has not yet been cloned, fail.
if err != nil {
if info != nil {
task.Warnf("git sync failed: %s", err)
return false, nil
}
return false, errors.Wrap(err, "git sync failed")
info, _ := os.Stat(s.path)
if info != nil && !force && time.Since(info.ModTime()) < SyncFrequency {
task.Debugf("Update skipped, updated within the last %s", SyncFrequency)
return false, nil
}

if err := s.ensureSourcesDirExists(); err != nil {
return false, errors.WithStack(err)
}

// Note the time *before* we start waiting for the lock: if, once we hold
// it, the directory's mtime is at or after this instant, another process
// finished synchronising it while we were waiting, and there is nothing
// left for us to do.
requestedAt := time.Now()
release, err := acquireSyncLock(task, s.path, s.lockTimeout, fmt.Sprintf("synchronising source %s", s.fs.uri))
if err != nil {
if info != nil {
// We already have a (possibly stale) usable copy. Don't fail the
// command just because we couldn't get exclusive access to
// refresh it.
task.Warnf("could not lock source for syncing, using existing copy: %s", err)
return false, nil
}
return false, errors.Wrap(err, "failed to sync sources")
}
defer release() //nolint:errcheck

// Double-checked locking: re-stat now that we hold the lock.
postLockInfo, _ := os.Stat(s.path)
if syncedSince(postLockInfo, requestedAt) {
task.Debugf("Update skipped, synchronised by another process")
return true, nil
}
task.Debugf("Update skipped, updated within the last %s", SyncFrequency)
return false, nil

err = syncGit(task, s.sourceDir, s.fs.uri, s.path, s.runner)
if err != nil {
// If the sync failed while the repo had already been cloned (using
// the up to date, post-lock information), log a warning. If the repo
// has not yet been cloned, fail.
if postLockInfo != nil {
task.Warnf("git sync failed: %s", err)
return false, nil
}
return false, errors.Wrap(err, "git sync failed")
}
return true, nil
}

// syncedSince reports whether "info" (the result of stat-ing a source
// directory) shows it was successfully synced at or after "since", allowing
// fsTimeGranularity of slack for filesystems that only store whole-second
// mtimes (eg. HFS+).
//
// That slack means syncedSince can report true for a sync that was actually
// requested up to fsTimeGranularity *after* the directory's real mtime --
// ie. Sync's double-checked-locking skip ("synchronised by another process")
// can fire even though the peer's sync, strictly, finished a moment before
// we asked. This is intentional and safe: skipping in that narrow window
// just means we use a copy that's at most fsTimeGranularity staler than the
// most pedantically-correct answer, which SyncFrequency-bounded staleness
// already tolerates far more of.
func syncedSince(info os.FileInfo, since time.Time) bool {
return info != nil && !info.ModTime().Add(fsTimeGranularity).Before(since)
}

func (s *GitSource) URI() string {
Expand All @@ -69,7 +142,55 @@ func (s *GitSource) ensureSourcesDirExists() error {
return nil
}

// Atomically clone git repo.
// incrementalBranch is the local branch syncGit's incremental-update path
// resets and checks out on every sync, instead of leaving finalDest in a
// detached-HEAD state. This is load-bearing, not cosmetic: "git clone"
// (without "--no-checkout" or "--depth") only copies a source's
// "refs/heads/*", not a detached HEAD, so a detached finalDest would give the
// next incremental sync's local clone of it zero branches to send as "have"s
// during the following "git fetch --depth=1". Without a "have", the server
// can't tell what the client already has, and sends a full pack for the
// requested commit -- silently degrading every sync after the first into the
// same "full fresh clone" cost this path exists to avoid, and (verified
// empirically) totally losing the branch by the second incremental sync.
// Keeping a real, persistent branch ref here means every later sync's local
// clone inherits it, so its fetch always has a "have" to negotiate against.
const incrementalBranch = "hermit"

// Atomically clone (or, if finalDest is already a clone, incrementally
// update) a git repo.
//
// There is deliberately no in-place "git pull" path: readers in other Hermit
// processes do not take the sync lock, so mutating finalDest's working tree
// in place (as "git pull" does -- updating and deleting files directly
// under it) is visible to them mid-update, and two concurrent "git pull"s
// against the same working tree can also collide with each other (eg. on
// ".git/index.lock"). Always building the new tree in a fresh directory and
// swapping it in atomically (see util.SwapDir) avoids both problems.
//
// When finalDest already has a ".git" directory, the new tree is built
// incrementally to keep the network cost close to a pull's: first a local,
// working-tree-less clone of finalDest (a same-filesystem copy, not a
// network operation), then a shallow fetch of just the latest commit from
// the real source into it, then a checkout of that commit onto
// incrementalBranch (see its doc comment for why a real branch, not a
// detached HEAD, is required for this to actually stay cheap on repeat
// syncs). A plain "--reference-if-able" clone from source was tried here
// first and discarded: finalDest is always itself a shallow (--depth=1)
// clone, and git unconditionally refuses to use a shallow repository as a
// reference, so that flag was silently a no-op and every sync was paying for
// a full fresh clone. This incremental path was verified against the real
// default source (632 manifests): ~0.9s versus ~3.3s for a fresh clone,
// close to the ~0.7s a "git pull" on an already-current clone takes -- and,
// separately, verified to stay that cheap across repeated syncs (not just
// the first one) once incrementalBranch was introduced.
//
// If the incremental update fails (eg. finalDest's ".git" is corrupt or
// truncated), this falls back to a fresh clone rather than surfacing the
// failure, so a damaged existing copy can still self-heal the way a from-
// scratch sync always could.
//
// The caller MUST hold the sync lock for finalDest.
func syncGit(b *ui.Task, dir, source, finalDest string, runner util.CommandRunner) (err error) {
task := b.SubProgress("sync", 1)
defer func() {
Expand All @@ -79,29 +200,87 @@ func syncGit(b *ui.Task, dir, source, finalDest string, runner util.CommandRunne
err = errors.WithStack(os.Chtimes(finalDest, now, now))
}
}()
// First, if a git repo exists, just pull.
info, _ := os.Stat(filepath.Join(finalDest, ".git"))
if info != nil {
err = runner.RunInDir(b, finalDest, "git", "pull")
if err == nil {
return nil
}
// If pull fails, assume the repo is corrupted and just try and re-clone it.
}
// No git repo, clone down to temporary directory.
dest, err := os.MkdirTemp(dir, filepath.Base(finalDest)+"-*")

removeStaleScratchDirs(b, dir, finalDest)

dest, err := os.MkdirTemp(dir, filepath.Base(finalDest)+tmpInfix+"*")
if err != nil {
return errors.WithStack(err)
}
defer os.RemoveAll(dest)
if err = runner.RunInDir(b, dest, "git", "clone", "--depth=1", source, dest); err != nil {

freshClone := true
if info, _ := os.Stat(filepath.Join(finalDest, ".git")); info != nil {
if incErr := syncGitIncremental(b, dest, source, finalDest, runner); incErr == nil {
freshClone = false
} else {
b.Warnf("incremental sync from existing clone failed, falling back to a fresh clone: %s", incErr)
if err = os.RemoveAll(dest); err != nil {
return errors.WithStack(err)
}
if err = os.Mkdir(dest, 0700); err != nil {
return errors.WithStack(err)
}
}
}
if freshClone {
if err = runner.RunInDir(b, dest, "git", "clone", "--depth=1", source, dest); err != nil {
return errors.WithStack(err)
}
}
return errors.WithStack(util.SwapDir(dest, finalDest))
}

// syncGitIncremental builds an updated tree at dest by cloning finalDest
// locally (same-filesystem, not a network operation) and fetching just the
// latest commit from the real source into it. See syncGit's doc comment for
// why the result is checked out onto incrementalBranch rather than left
// detached.
func syncGitIncremental(b *ui.Task, dest, source, finalDest string, runner util.CommandRunner) error {
if err := runner.RunInDir(b, dest, "git", "clone", "--no-checkout", finalDest, dest); err != nil {
return errors.WithStack(err)
}
_ = os.RemoveAll(finalDest)
// And finally, rename it into place.
if err = os.Rename(dest, finalDest); err != nil && !os.IsExist(err) { // Prevent races.
if err := runner.RunInDir(b, dest, "git", "fetch", "--depth=1", source, "HEAD"); err != nil {
return errors.WithStack(err)
}
// "--force" makes materialising the worktree here not depend on dest
// having no ".git/index" (true today, since "clone --no-checkout" writes
// none) -- without it, a checkout that git considers a no-op change
// writes nothing, silently leaving dest's worktree empty were that ever
// no longer the case.
return errors.WithStack(runner.RunInDir(b, dest, "git", "checkout", "--force", "-B", incrementalBranch, "FETCH_HEAD"))
}

return nil
// removeStaleScratchDirs removes leftover clone/swap scratch directories from
// Hermit processes that were killed mid-sync (eg. SIGKILL, which the
// "defer os.RemoveAll" in syncGit cannot run for), including the "<hash>-XXXX"
// form used by Hermit versions prior to the introduction of source locking.
//
// The caller MUST hold the sync lock for finalDest. This is best-effort;
// errors are ignored.
func removeStaleScratchDirs(log ui.Logger, dir, finalDest string) {
base := filepath.Base(finalDest)
entries, err := os.ReadDir(dir)
if err != nil {
return
}
for _, entry := range entries {
name := entry.Name()
if name == base || strings.HasSuffix(name, lockSuffix) {
continue
}
if !strings.HasPrefix(name, base+tmpInfix) &&
name != base+asideSuffix &&
!strings.HasPrefix(name, base+legacyTmpInfix) {
continue
}
info, err := entry.Info()
if err != nil || time.Since(info.ModTime()) < staleScratchAge {
// Generous age threshold: an older, unlocked Hermit binary may
// still be actively cloning into one of these.
continue
}
log.Debugf("removing stale source scratch directory %s", name)
_ = os.RemoveAll(filepath.Join(dir, name))
}
}
Loading