about summary refs log tree commit diff
path: root/third_party/nix/src/libstore/gc.cc
diff options
context:
space:
mode:
Diffstat (limited to 'third_party/nix/src/libstore/gc.cc')
-rw-r--r--third_party/nix/src/libstore/gc.cc997
1 files changed, 997 insertions, 0 deletions
diff --git a/third_party/nix/src/libstore/gc.cc b/third_party/nix/src/libstore/gc.cc
new file mode 100644
index 000000000000..07dc10629a91
--- /dev/null
+++ b/third_party/nix/src/libstore/gc.cc
@@ -0,0 +1,997 @@
+#include <algorithm>
+#include <cerrno>
+#include <climits>
+#include <functional>
+#include <queue>
+#include <random>
+#include <regex>
+
+#include <absl/strings/match.h>
+#include <absl/strings/str_cat.h>
+#include <absl/strings/str_split.h>
+#include <fcntl.h>
+#include <glog/logging.h>
+#include <sys/stat.h>
+#include <sys/statvfs.h>
+#include <sys/types.h>
+#include <unistd.h>
+
+#include "libstore/derivations.hh"
+#include "libstore/globals.hh"
+#include "libstore/local-store.hh"
+#include "libutil/finally.hh"
+
+namespace nix {
+
+constexpr std::string_view kGcLockName = "gc.lock";
+constexpr std::string_view kGcRootsDir = "gcroots";
+
+/* Acquire the global GC lock.  This is used to prevent new Nix
+   processes from starting after the temporary root files have been
+   read.  To be precise: when they try to create a new temporary root
+   file, they will block until the garbage collector has finished /
+   yielded the GC lock. */
+AutoCloseFD LocalStore::openGCLock(LockType lockType) {
+  Path fnGCLock = absl::StrCat(stateDir.get(), "/", kGcLockName);
+
+  DLOG(INFO) << "acquiring global GC lock " << fnGCLock;
+
+  AutoCloseFD fdGCLock(
+      open(fnGCLock.c_str(), O_RDWR | O_CREAT | O_CLOEXEC, 0600));
+
+  if (!fdGCLock) {
+    throw SysError(format("opening global GC lock '%1%'") % fnGCLock);
+  }
+
+  if (!lockFile(fdGCLock.get(), lockType, false)) {
+    LOG(ERROR) << "waiting for the big garbage collector lock...";
+    lockFile(fdGCLock.get(), lockType, true);
+  }
+
+  /* !!! Restrict read permission on the GC root.  Otherwise any
+     process that can open the file for reading can DoS the
+     collector. */
+
+  return fdGCLock;
+}
+
+static void makeSymlink(const Path& link, const Path& target) {
+  /* Create directories up to `gcRoot'. */
+  createDirs(dirOf(link));
+
+  /* Create the new symlink. */
+  Path tempLink =
+      (format("%1%.tmp-%2%-%3%") % link % getpid() % random()).str();
+  createSymlink(target, tempLink);
+
+  /* Atomically replace the old one. */
+  if (rename(tempLink.c_str(), link.c_str()) == -1) {
+    throw SysError(format("cannot rename '%1%' to '%2%'") % tempLink % link);
+  }
+}
+
+void LocalStore::syncWithGC() { AutoCloseFD fdGCLock = openGCLock(ltRead); }
+
+void LocalStore::addIndirectRoot(const Path& path) {
+  std::string hash = hashString(htSHA1, path).to_string(Base32, false);
+  Path realRoot =
+      canonPath(absl::StrCat(stateDir.get(), "/", kGcRootsDir, "/auto/", hash));
+  makeSymlink(realRoot, path);
+}
+
+Path LocalFSStore::addPermRoot(const Path& _storePath, const Path& _gcRoot,
+                               bool indirect, bool allowOutsideRootsDir) {
+  Path storePath(canonPath(_storePath));
+  Path gcRoot(canonPath(_gcRoot));
+  assertStorePath(storePath);
+
+  if (isInStore(gcRoot)) {
+    throw Error(format("creating a garbage collector root (%1%) in the Nix "
+                       "store is forbidden "
+                       "(are you running nix-build inside the store?)") %
+                gcRoot);
+  }
+
+  if (indirect) {
+    /* Don't clobber the link if it already exists and doesn't
+       point to the Nix store. */
+    if (pathExists(gcRoot) &&
+        (!isLink(gcRoot) || !isInStore(readLink(gcRoot)))) {
+      throw Error(format("cannot create symlink '%1%'; already exists") %
+                  gcRoot);
+    }
+    makeSymlink(gcRoot, storePath);
+    addIndirectRoot(gcRoot);
+  }
+
+  else {
+    if (!allowOutsideRootsDir) {
+      Path rootsDir = canonPath(absl::StrCat(stateDir.get(), "/", kGcRootsDir));
+
+      if (std::string(gcRoot, 0, rootsDir.size() + 1) != rootsDir + "/") {
+        throw Error(format("path '%1%' is not a valid garbage collector root; "
+                           "it's not in the directory '%2%'") %
+                    gcRoot % rootsDir);
+      }
+    }
+
+    if (baseNameOf(gcRoot) == baseNameOf(storePath)) {
+      writeFile(gcRoot, "");
+    } else {
+      makeSymlink(gcRoot, storePath);
+    }
+  }
+
+  /* Check that the root can be found by the garbage collector.
+     !!! This can be very slow on machines that have many roots.
+     Instead of reading all the roots, it would be more efficient to
+     check if the root is in a directory in or linked from the
+     gcroots directory. */
+  if (settings.checkRootReachability) {
+    Roots roots = findRoots(false);
+    if (roots[storePath].count(gcRoot) == 0) {
+      LOG(ERROR) << "warning: '" << gcRoot
+                 << "' is not in a directory where the garbage "
+                 << "collector looks for roots; therefore, '" << storePath
+                 << "' might be removed by the garbage collector";
+    }
+  }
+
+  /* Grab the global GC root, causing us to block while a GC is in
+     progress.  This prevents the set of permanent roots from
+     increasing while a GC is in progress. */
+  syncWithGC();
+
+  return gcRoot;
+}
+
+void LocalStore::addTempRoot(const Path& path) {
+  auto state(_state.lock());
+
+  /* Create the temporary roots file for this process. */
+  if (!state->fdTempRoots) {
+    while (true) {
+      AutoCloseFD fdGCLock = openGCLock(ltRead);
+
+      if (pathExists(fnTempRoots)) {
+        /* It *must* be stale, since there can be no two
+           processes with the same pid. */
+        unlink(fnTempRoots.c_str());
+      }
+
+      state->fdTempRoots = openLockFile(fnTempRoots, true);
+
+      fdGCLock = AutoCloseFD(-1);
+
+      DLOG(INFO) << "acquiring read lock on " << fnTempRoots;
+      lockFile(state->fdTempRoots.get(), ltRead, true);
+
+      /* Check whether the garbage collector didn't get in our
+         way. */
+      struct stat st;
+      if (fstat(state->fdTempRoots.get(), &st) == -1) {
+        throw SysError(format("statting '%1%'") % fnTempRoots);
+      }
+      if (st.st_size == 0) {
+        break;
+      }
+
+      /* The garbage collector deleted this file before we could
+         get a lock.  (It won't delete the file after we get a
+         lock.)  Try again. */
+    }
+  }
+
+  /* Upgrade the lock to a write lock.  This will cause us to block
+     if the garbage collector is holding our lock. */
+  DLOG(INFO) << "acquiring write lock on " << fnTempRoots;
+  lockFile(state->fdTempRoots.get(), ltWrite, true);
+
+  std::string s = path + '\0';
+  writeFull(state->fdTempRoots.get(), s);
+
+  /* Downgrade to a read lock. */
+  DLOG(INFO) << "downgrading to read lock on " << fnTempRoots;
+  lockFile(state->fdTempRoots.get(), ltRead, true);
+}
+
+constexpr std::string_view kCensored = "{censored}";
+
+void LocalStore::findTempRoots(FDs& fds, Roots& tempRoots, bool censor) {
+  /* Read the `temproots' directory for per-process temporary root
+     files. */
+  for (auto& i : readDirectory(tempRootsDir)) {
+    Path path = tempRootsDir + "/" + i.name;
+
+    pid_t pid = std::stoi(i.name);
+
+    DLOG(INFO) << "reading temporary root file " << path;
+    FDPtr fd(new AutoCloseFD(open(path.c_str(), O_CLOEXEC | O_RDWR, 0666)));
+    if (!*fd) {
+      /* It's okay if the file has disappeared. */
+      if (errno == ENOENT) {
+        continue;
+      }
+      throw SysError(format("opening temporary roots file '%1%'") % path);
+    }
+
+    /* This should work, but doesn't, for some reason. */
+    // FDPtr fd(new AutoCloseFD(openLockFile(path, false)));
+    // if (*fd == -1) { continue; }
+
+    /* Try to acquire a write lock without blocking.  This can
+       only succeed if the owning process has died.  In that case
+       we don't care about its temporary roots. */
+    if (lockFile(fd->get(), ltWrite, false)) {
+      LOG(ERROR) << "removing stale temporary roots file " << path;
+      unlink(path.c_str());
+      writeFull(fd->get(), "d");
+      continue;
+    }
+
+    /* Acquire a read lock.  This will prevent the owning process
+       from upgrading to a write lock, therefore it will block in
+       addTempRoot(). */
+    DLOG(INFO) << "waiting for read lock on " << path;
+    lockFile(fd->get(), ltRead, true);
+
+    /* Read the entire file. */
+    std::string contents = readFile(fd->get());
+
+    /* Extract the roots. */
+    std::string::size_type pos = 0;
+    std::string::size_type end;
+
+    while ((end = contents.find(static_cast<char>(0), pos)) !=
+           std::string::npos) {
+      Path root(contents, pos, end - pos);
+      DLOG(INFO) << "got temporary root " << root;
+      assertStorePath(root);
+      tempRoots[root].emplace(censor ? kCensored : fmt("{temp:%d}", pid));
+      pos = end + 1;
+    }
+
+    fds.push_back(fd); /* keep open */
+  }
+}
+
+void LocalStore::findRoots(const Path& path, unsigned char type, Roots& roots) {
+  auto foundRoot = [&](const Path& path, const Path& target) {
+    Path storePath = toStorePath(target);
+    if (isStorePath(storePath) && isValidPath(storePath)) {
+      roots[storePath].emplace(path);
+    } else {
+      LOG(INFO) << "skipping invalid root from '" << path << "' to '"
+                << storePath << "'";
+    }
+  };
+
+  try {
+    if (type == DT_UNKNOWN) {
+      type = getFileType(path);
+    }
+
+    if (type == DT_DIR) {
+      for (auto& i : readDirectory(path)) {
+        findRoots(path + "/" + i.name, i.type, roots);
+      }
+    }
+
+    else if (type == DT_LNK) {
+      Path target = readLink(path);
+      if (isInStore(target)) {
+        foundRoot(path, target);
+      }
+
+      /* Handle indirect roots. */
+      else {
+        target = absPath(target, dirOf(path));
+        if (!pathExists(target)) {
+          if (isInDir(path, absl::StrCat(stateDir.get(), "/", kGcRootsDir,
+                                         "/auto"))) {
+            LOG(INFO) << "removing stale link from '" << path << "' to '"
+                      << target << "'";
+            unlink(path.c_str());
+          }
+        } else {
+          struct stat st2 = lstat(target);
+          if (!S_ISLNK(st2.st_mode)) {
+            return;
+          }
+          Path target2 = readLink(target);
+          if (isInStore(target2)) {
+            foundRoot(target, target2);
+          }
+        }
+      }
+    }
+
+    else if (type == DT_REG) {
+      Path storePath = storeDir + "/" + baseNameOf(path);
+      if (isStorePath(storePath) && isValidPath(storePath)) {
+        roots[storePath].emplace(path);
+      }
+    }
+
+  }
+
+  catch (SysError& e) {
+    /* We only ignore permanent failures. */
+    if (e.errNo == EACCES || e.errNo == ENOENT || e.errNo == ENOTDIR) {
+      LOG(INFO) << "cannot read potential root '" << path << "'";
+    } else {
+      throw;
+    }
+  }
+}
+
+void LocalStore::findRootsNoTemp(Roots& roots, bool censor) {
+  /* Process direct roots in {gcroots,profiles}. */
+  findRoots(absl::StrCat(stateDir.get(), "/", kGcRootsDir), DT_UNKNOWN, roots);
+  findRoots(stateDir + "/profiles", DT_UNKNOWN, roots);
+
+  /* Add additional roots returned by different platforms-specific
+     heuristics.  This is typically used to add running programs to
+     the set of roots (to prevent them from being garbage collected). */
+  findRuntimeRoots(roots, censor);
+}
+
+Roots LocalStore::findRoots(bool censor) {
+  Roots roots;
+  findRootsNoTemp(roots, censor);
+
+  FDs fds;
+  findTempRoots(fds, roots, censor);
+
+  return roots;
+}
+
+static void readProcLink(const std::string& file, Roots& roots) {
+  /* 64 is the starting buffer size gnu readlink uses... */
+  auto bufsiz = ssize_t{64};
+try_again:
+  char buf[bufsiz];
+  auto res = readlink(file.c_str(), buf, bufsiz);
+  if (res == -1) {
+    if (errno == ENOENT || errno == EACCES || errno == ESRCH) {
+      return;
+    }
+    throw SysError("reading symlink");
+  }
+  if (res == bufsiz) {
+    if (SSIZE_MAX / 2 < bufsiz) {
+      throw Error("stupidly long symlink");
+    }
+    bufsiz *= 2;
+    goto try_again;
+  }
+  if (res > 0 && buf[0] == '/') {
+    roots[std::string(static_cast<char*>(buf), res)].emplace(file);
+  }
+}
+
+static std::string quoteRegexChars(const std::string& raw) {
+  static auto specialRegex = std::regex(R"([.^$\\*+?()\[\]{}|])");
+  return std::regex_replace(raw, specialRegex, R"(\$&)");
+}
+
+static void readFileRoots(const char* path, Roots& roots) {
+  try {
+    roots[readFile(path)].emplace(path);
+  } catch (SysError& e) {
+    if (e.errNo != ENOENT && e.errNo != EACCES) {
+      throw;
+    }
+  }
+}
+
+void LocalStore::findRuntimeRoots(Roots& roots, bool censor) {
+  Roots unchecked;
+
+  auto procDir = AutoCloseDir{opendir("/proc")};
+  if (procDir) {
+    struct dirent* ent;
+    auto digitsRegex = std::regex(R"(^\d+$)");
+    auto mapRegex =
+        std::regex(R"(^\s*\S+\s+\S+\s+\S+\s+\S+\s+\S+\s+(/\S+)\s*$)");
+    auto storePathRegex = std::regex(quoteRegexChars(storeDir) +
+                                     R"(/[0-9a-z]+[0-9a-zA-Z\+\-\._\?=]*)");
+    while (errno = 0, ent = readdir(procDir.get())) {
+      checkInterrupt();
+      if (std::regex_match(ent->d_name, digitsRegex)) {
+        readProcLink(fmt("/proc/%s/exe", ent->d_name), unchecked);
+        readProcLink(fmt("/proc/%s/cwd", ent->d_name), unchecked);
+
+        auto fdStr = fmt("/proc/%s/fd", ent->d_name);
+        auto fdDir = AutoCloseDir(opendir(fdStr.c_str()));
+        if (!fdDir) {
+          if (errno == ENOENT || errno == EACCES) {
+            continue;
+          }
+          throw SysError(format("opening %1%") % fdStr);
+        }
+        struct dirent* fd_ent;
+        while (errno = 0, fd_ent = readdir(fdDir.get())) {
+          if (fd_ent->d_name[0] != '.') {
+            readProcLink(fmt("%s/%s", fdStr, fd_ent->d_name), unchecked);
+          }
+        }
+        if (errno) {
+          if (errno == ESRCH) {
+            continue;
+          }
+          throw SysError(format("iterating /proc/%1%/fd") % ent->d_name);
+        }
+        fdDir.reset();
+
+        try {
+          auto mapFile = fmt("/proc/%s/maps", ent->d_name);
+          std::vector<std::string> mapLines = absl::StrSplit(
+              readFile(mapFile, true), absl::ByChar('\n'), absl::SkipEmpty());
+          for (const auto& line : mapLines) {
+            auto match = std::smatch{};
+            if (std::regex_match(line, match, mapRegex)) {
+              unchecked[match[1]].emplace(mapFile);
+            }
+          }
+
+          auto envFile = fmt("/proc/%s/environ", ent->d_name);
+          auto envString = readFile(envFile, true);
+          auto env_end = std::sregex_iterator{};
+          for (auto i = std::sregex_iterator{envString.begin(), envString.end(),
+                                             storePathRegex};
+               i != env_end; ++i) {
+            unchecked[i->str()].emplace(envFile);
+          }
+        } catch (SysError& e) {
+          if (errno == ENOENT || errno == EACCES || errno == ESRCH) {
+            continue;
+          }
+          throw;
+        }
+      }
+    }
+    if (errno) {
+      throw SysError("iterating /proc");
+    }
+  }
+
+  readFileRoots("/proc/sys/kernel/modprobe", unchecked);
+  readFileRoots("/proc/sys/kernel/fbsplash", unchecked);
+  readFileRoots("/proc/sys/kernel/poweroff_cmd", unchecked);
+
+  for (auto& [target, links] : unchecked) {
+    if (isInStore(target)) {
+      Path path = toStorePath(target);
+      if (isStorePath(path) && isValidPath(path)) {
+        DLOG(INFO) << "got additional root " << path;
+        if (censor) {
+          roots[path].insert(std::string(kCensored));
+        } else {
+          roots[path].insert(links.begin(), links.end());
+        }
+      }
+    }
+  }
+}
+
+struct GCLimitReached {};
+
+struct LocalStore::GCState {
+  GCOptions options;
+  GCResults& results;
+  PathSet roots;
+  PathSet tempRoots;
+  PathSet dead;
+  PathSet alive;
+  bool gcKeepOutputs;
+  bool gcKeepDerivations;
+  unsigned long long bytesInvalidated;
+  bool moveToTrash = true;
+  bool shouldDelete;
+  explicit GCState(GCResults& results_)
+      : results(results_), bytesInvalidated(0) {}
+};
+
+bool LocalStore::isActiveTempFile(const GCState& state, const Path& path,
+                                  const std::string& suffix) {
+  return absl::EndsWith(path, suffix) &&
+         state.tempRoots.find(std::string(
+             path, 0, path.size() - suffix.size())) != state.tempRoots.end();
+}
+
+void LocalStore::deleteGarbage(GCState& state, const Path& path) {
+  unsigned long long bytesFreed;
+  deletePath(path, bytesFreed);
+  state.results.bytesFreed += bytesFreed;
+}
+
+void LocalStore::deletePathRecursive(GCState& state, const Path& path) {
+  checkInterrupt();
+
+  unsigned long long size = 0;
+
+  if (isStorePath(path) && isValidPath(path)) {
+    PathSet referrers;
+    queryReferrers(path, referrers);
+    for (auto& i : referrers) {
+      if (i != path) {
+        deletePathRecursive(state, i);
+      }
+    }
+    size = queryPathInfo(path)->narSize;
+    invalidatePathChecked(path);
+  }
+
+  Path realPath = realStoreDir + "/" + baseNameOf(path);
+
+  struct stat st;
+  if (lstat(realPath.c_str(), &st) != 0) {
+    if (errno == ENOENT) {
+      return;
+    }
+    throw SysError(format("getting status of %1%") % realPath);
+  }
+
+  LOG(INFO) << "deleting '" << path << "'";
+
+  state.results.paths.insert(path);
+
+  /* If the path is not a regular file or symlink, move it to the
+     trash directory.  The move is to ensure that later (when we're
+     not holding the global GC lock) we can delete the path without
+     being afraid that the path has become alive again.  Otherwise
+     delete it right away. */
+  if (state.moveToTrash && S_ISDIR(st.st_mode)) {
+    // Estimate the amount freed using the narSize field.  FIXME:
+    // if the path was not valid, need to determine the actual
+    // size.
+    try {
+      if (chmod(realPath.c_str(), st.st_mode | S_IWUSR) == -1) {
+        throw SysError(format("making '%1%' writable") % realPath);
+      }
+      Path tmp = trashDir + "/" + baseNameOf(path);
+      if (rename(realPath.c_str(), tmp.c_str()) != 0) {
+        throw SysError(format("unable to rename '%1%' to '%2%'") % realPath %
+                       tmp);
+      }
+      state.bytesInvalidated += size;
+    } catch (SysError& e) {
+      if (e.errNo == ENOSPC) {
+        LOG(INFO) << "note: can't create move '" << realPath
+                  << "': " << e.msg();
+        deleteGarbage(state, realPath);
+      }
+    }
+  } else {
+    deleteGarbage(state, realPath);
+  }
+
+  if (state.results.bytesFreed + state.bytesInvalidated >
+      state.options.maxFreed) {
+    LOG(INFO) << "deleted or invalidated more than " << state.options.maxFreed
+              << " bytes; stopping";
+    throw GCLimitReached();
+  }
+}
+
+bool LocalStore::canReachRoot(GCState& state, PathSet& visited,
+                              const Path& path) {
+  if (visited.count(path) != 0u) {
+    return false;
+  }
+
+  if (state.alive.count(path) != 0u) {
+    return true;
+  }
+
+  if (state.dead.count(path) != 0u) {
+    return false;
+  }
+
+  if (state.roots.count(path) != 0u) {
+    DLOG(INFO) << "cannot delete '" << path << "' because it's a root";
+    state.alive.insert(path);
+    return true;
+  }
+
+  visited.insert(path);
+
+  if (!isStorePath(path) || !isValidPath(path)) {
+    return false;
+  }
+
+  PathSet incoming;
+
+  /* Don't delete this path if any of its referrers are alive. */
+  queryReferrers(path, incoming);
+
+  /* If keep-derivations is set and this is a derivation, then
+     don't delete the derivation if any of the outputs are alive. */
+  if (state.gcKeepDerivations && isDerivation(path)) {
+    PathSet outputs = queryDerivationOutputs(path);
+    for (auto& i : outputs) {
+      if (isValidPath(i) && queryPathInfo(i)->deriver == path) {
+        incoming.insert(i);
+      }
+    }
+  }
+
+  /* If keep-outputs is set, then don't delete this path if there
+     are derivers of this path that are not garbage. */
+  if (state.gcKeepOutputs) {
+    PathSet derivers = queryValidDerivers(path);
+    for (auto& i : derivers) {
+      incoming.insert(i);
+    }
+  }
+
+  for (auto& i : incoming) {
+    if (i != path) {
+      if (canReachRoot(state, visited, i)) {
+        state.alive.insert(path);
+        return true;
+      }
+    }
+  }
+
+  return false;
+}
+
+void LocalStore::tryToDelete(GCState& state, const Path& path) {
+  checkInterrupt();
+
+  auto realPath = realStoreDir + "/" + baseNameOf(path);
+  if (realPath == linksDir || realPath == trashDir) {
+    return;
+  }
+
+  // Activity act(*logger, lvlDebug, format("considering whether to delete
+  // '%1%'") % path);
+
+  if (!isStorePath(path) || !isValidPath(path)) {
+    /* A lock file belonging to a path that we're building right
+       now isn't garbage. */
+    if (isActiveTempFile(state, path, ".lock")) {
+      return;
+    }
+
+    /* Don't delete .chroot directories for derivations that are
+       currently being built. */
+    if (isActiveTempFile(state, path, ".chroot")) {
+      return;
+    }
+
+    /* Don't delete .check directories for derivations that are
+       currently being built, because we may need to run
+       diff-hook. */
+    if (isActiveTempFile(state, path, ".check")) {
+      return;
+    }
+  }
+
+  PathSet visited;
+
+  if (canReachRoot(state, visited, path)) {
+    DLOG(INFO) << "cannot delete '" << path << "' because it's still reachable";
+  } else {
+    /* No path we visited was a root, so everything is garbage.
+       But we only delete ‘path’ and its referrers here so that
+       ‘nix-store --delete’ doesn't have the unexpected effect of
+       recursing into derivations and outputs. */
+    state.dead.insert(visited.begin(), visited.end());
+    if (state.shouldDelete) {
+      deletePathRecursive(state, path);
+    }
+  }
+}
+
+/* Unlink all files in /nix/store/.links that have a link count of 1,
+   which indicates that there are no other links and so they can be
+   safely deleted.  FIXME: race condition with optimisePath(): we
+   might see a link count of 1 just before optimisePath() increases
+   the link count. */
+void LocalStore::removeUnusedLinks(const GCState& state) {
+  AutoCloseDir dir(opendir(linksDir.c_str()));
+  if (!dir) {
+    throw SysError(format("opening directory '%1%'") % linksDir);
+  }
+
+  long long actualSize = 0;
+  long long unsharedSize = 0;
+
+  struct dirent* dirent;
+  while (errno = 0, dirent = readdir(dir.get())) {
+    checkInterrupt();
+    std::string name = dirent->d_name;
+    if (name == "." || name == "..") {
+      continue;
+    }
+    Path path = linksDir + "/" + name;
+
+    struct stat st;
+    if (lstat(path.c_str(), &st) == -1) {
+      throw SysError(format("statting '%1%'") % path);
+    }
+
+    if (st.st_nlink != 1) {
+      actualSize += st.st_size;
+      unsharedSize += (st.st_nlink - 1) * st.st_size;
+      continue;
+    }
+
+    LOG(INFO) << "deleting unused link " << path;
+
+    if (unlink(path.c_str()) == -1) {
+      throw SysError(format("deleting '%1%'") % path);
+    }
+
+    state.results.bytesFreed += st.st_size;
+  }
+
+  struct stat st;
+  if (stat(linksDir.c_str(), &st) == -1) {
+    throw SysError(format("statting '%1%'") % linksDir);
+  }
+
+  long long overhead = st.st_blocks * 512ULL;
+
+  // TODO(tazjin): absl::StrFormat %.2f
+  LOG(INFO) << "note: currently hard linking saves "
+            << ((unsharedSize - actualSize - overhead) / (1024.0 * 1024.0))
+            << " MiB";
+}
+
+void LocalStore::collectGarbage(const GCOptions& options, GCResults& results) {
+  GCState state(results);
+  state.options = options;
+  state.gcKeepOutputs = settings.gcKeepOutputs;
+  state.gcKeepDerivations = settings.gcKeepDerivations;
+
+  /* Using `--ignore-liveness' with `--delete' can have unintended
+     consequences if `keep-outputs' or `keep-derivations' are true
+     (the garbage collector will recurse into deleting the outputs
+     or derivers, respectively).  So disable them. */
+  if (options.action == GCOptions::gcDeleteSpecific && options.ignoreLiveness) {
+    state.gcKeepOutputs = false;
+    state.gcKeepDerivations = false;
+  }
+
+  state.shouldDelete = options.action == GCOptions::gcDeleteDead ||
+                       options.action == GCOptions::gcDeleteSpecific;
+
+  if (state.shouldDelete) {
+    deletePath(reservedPath);
+  }
+
+  /* Acquire the global GC root.  This prevents
+     a) New roots from being added.
+     b) Processes from creating new temporary root files. */
+  AutoCloseFD fdGCLock = openGCLock(ltWrite);
+
+  /* Find the roots.  Since we've grabbed the GC lock, the set of
+     permanent roots cannot increase now. */
+  LOG(INFO) << "finding garbage collector roots...";
+  Roots rootMap;
+  if (!options.ignoreLiveness) {
+    findRootsNoTemp(rootMap, true);
+  }
+
+  for (auto& i : rootMap) {
+    state.roots.insert(i.first);
+  }
+
+  /* Read the temporary roots.  This acquires read locks on all
+     per-process temporary root files.  So after this point no paths
+     can be added to the set of temporary roots. */
+  FDs fds;
+  Roots tempRoots;
+  findTempRoots(fds, tempRoots, true);
+  for (auto& root : tempRoots) {
+    state.tempRoots.insert(root.first);
+  }
+  state.roots.insert(state.tempRoots.begin(), state.tempRoots.end());
+
+  /* After this point the set of roots or temporary roots cannot
+     increase, since we hold locks on everything.  So everything
+     that is not reachable from `roots' is garbage. */
+
+  if (state.shouldDelete) {
+    if (pathExists(trashDir)) {
+      deleteGarbage(state, trashDir);
+    }
+    try {
+      createDirs(trashDir);
+    } catch (SysError& e) {
+      if (e.errNo == ENOSPC) {
+        LOG(INFO) << "note: can't create trash directory: " << e.msg();
+        state.moveToTrash = false;
+      }
+    }
+  }
+
+  /* Now either delete all garbage paths, or just the specified
+     paths (for gcDeleteSpecific). */
+
+  if (options.action == GCOptions::gcDeleteSpecific) {
+    for (auto& i : options.pathsToDelete) {
+      assertStorePath(i);
+      tryToDelete(state, i);
+      if (state.dead.find(i) == state.dead.end()) {
+        throw Error(format("cannot delete path '%1%' since it is still alive") %
+                    i);
+      }
+    }
+
+  } else if (options.maxFreed > 0) {
+    if (state.shouldDelete) {
+      LOG(INFO) << "deleting garbage...";
+    } else {
+      LOG(ERROR) << "determining live/dead paths...";
+    }
+
+    try {
+      AutoCloseDir dir(opendir(realStoreDir.c_str()));
+      if (!dir) {
+        throw SysError(format("opening directory '%1%'") % realStoreDir);
+      }
+
+      /* Read the store and immediately delete all paths that
+         aren't valid.  When using --max-freed etc., deleting
+         invalid paths is preferred over deleting unreachable
+         paths, since unreachable paths could become reachable
+         again.  We don't use readDirectory() here so that GCing
+         can start faster. */
+      Paths entries;
+      struct dirent* dirent;
+      while (errno = 0, dirent = readdir(dir.get())) {
+        checkInterrupt();
+        std::string name = dirent->d_name;
+        if (name == "." || name == "..") {
+          continue;
+        }
+        Path path = storeDir + "/" + name;
+        if (isStorePath(path) && isValidPath(path)) {
+          entries.push_back(path);
+        } else {
+          tryToDelete(state, path);
+        }
+      }
+
+      dir.reset();
+
+      /* Now delete the unreachable valid paths.  Randomise the
+         order in which we delete entries to make the collector
+         less biased towards deleting paths that come
+         alphabetically first (e.g. /nix/store/000...).  This
+         matters when using --max-freed etc. */
+      std::vector<Path> entries_(entries.begin(), entries.end());
+      std::mt19937 gen(1);
+      std::shuffle(entries_.begin(), entries_.end(), gen);
+
+      for (auto& i : entries_) {
+        tryToDelete(state, i);
+      }
+
+    } catch (GCLimitReached& e) {
+    }
+  }
+
+  if (state.options.action == GCOptions::gcReturnLive) {
+    state.results.paths = state.alive;
+    return;
+  }
+
+  if (state.options.action == GCOptions::gcReturnDead) {
+    state.results.paths = state.dead;
+    return;
+  }
+
+  /* Allow other processes to add to the store from here on. */
+  fdGCLock = AutoCloseFD(-1);
+  fds.clear();
+
+  /* Delete the trash directory. */
+  LOG(INFO) << "deleting " << trashDir;
+  deleteGarbage(state, trashDir);
+
+  /* Clean up the links directory. */
+  if (options.action == GCOptions::gcDeleteDead ||
+      options.action == GCOptions::gcDeleteSpecific) {
+    LOG(INFO) << "deleting unused links...";
+    removeUnusedLinks(state);
+  }
+
+  /* While we're at it, vacuum the database. */
+  // if (options.action == GCOptions::gcDeleteDead) { vacuumDB(); }
+}
+
+void LocalStore::autoGC(bool sync) {
+  static auto fakeFreeSpaceFile =
+      getEnv("_NIX_TEST_FREE_SPACE_FILE").value_or("");
+
+  auto getAvail = [this]() -> uint64_t {
+    if (!fakeFreeSpaceFile.empty()) {
+      return std::stoll(readFile(fakeFreeSpaceFile));
+    }
+
+    struct statvfs st;
+    if (statvfs(realStoreDir.c_str(), &st) != 0) {
+      throw SysError("getting filesystem info about '%s'", realStoreDir);
+    }
+
+    return static_cast<uint64_t>(st.f_bavail) * st.f_bsize;
+  };
+
+  std::shared_future<void> future;
+
+  {
+    auto state(_state.lock());
+
+    if (state->gcRunning) {
+      future = state->gcFuture;
+      DLOG(INFO) << "waiting for auto-GC to finish";
+      goto sync;
+    }
+
+    auto now = std::chrono::steady_clock::now();
+
+    if (now < state->lastGCCheck +
+                  std::chrono::seconds(settings.minFreeCheckInterval)) {
+      return;
+    }
+
+    auto avail = getAvail();
+
+    state->lastGCCheck = now;
+
+    if (avail >= settings.minFree || avail >= settings.maxFree) {
+      return;
+    }
+
+    if (avail > state->availAfterGC * 0.97) {
+      return;
+    }
+
+    state->gcRunning = true;
+
+    std::promise<void> promise;
+    future = state->gcFuture = promise.get_future().share();
+
+    std::thread([promise{std::move(promise)}, this, avail, getAvail]() mutable {
+      try {
+        /* Wake up any threads waiting for the auto-GC to finish. */
+        Finally wakeup([&]() {
+          auto state(_state.lock());
+          state->gcRunning = false;
+          state->lastGCCheck = std::chrono::steady_clock::now();
+          promise.set_value();
+        });
+
+        GCOptions options;
+        options.maxFreed = settings.maxFree - avail;
+
+        LOG(INFO) << "running auto-GC to free " << options.maxFreed << " bytes";
+
+        GCResults results;
+
+        collectGarbage(options, results);
+
+        _state.lock()->availAfterGC = getAvail();
+
+      } catch (...) {
+        // FIXME: we could propagate the exception to the
+        // future, but we don't really care.
+        ignoreException();
+      }
+    }).detach();
+  }
+
+sync:
+  // Wait for the future outside of the state lock.
+  if (sync) {
+    future.get();
+  }
+}
+
+}  // namespace nix