OverviewFilesCommitsIssuesDocsReleasesPeers

pgh / lib / preflight.js

131 lines · javascript ref:
/** Filesystem preflight.
 *
 * pgh requires a POSIX filesystem that supports **unix domain sockets** and
 * **symlinks**, and benefits from **hardlinks** (content-addressed dedup).
 *
 *   - unix sockets: the sidecar + every daemon bind a UDS (~/.pgh/svc.sock).
 *     No fallback — if the fs can't host an AF_UNIX socket, nothing runs.
 *   - symlinks: the node materializer wires node_modules with symlinks
 *     (lib/materializers/node.js). No fallback — materialize throws.
 *   - hardlinks: the store dedups blobs by hardlinking one inode into every
 *     app tree (lib/store.js). SOFT — degrades to copy on EXDEV/EPERM/etc.,
 *     so a hardlink-less fs still works, it just uses more disk.
 *
 * Classic failure surfaces, and why each fails (this probe is EMPIRICAL — it
 * tests the capabilities, it does NOT match on filesystem name):
 *   - FAT / vfat / exFAT: none of the three (no symlinks/hardlinks/special
 *     files) — fails symlink AND socket.
 *   - NTFS (ntfs3/ntfs-3g): HAS symlinks + hardlinks, but can't host a
 *     unix-socket special file — fails on the socket alone.
 *   - WSL Windows-drive bridge (`/mnt/c` via drvfs/9p): no unix sockets,
 *     unreliable symlinks.
 * Catch it here, before init gets deep.
 */

import fs from '#node:fs'
import path from '#node:path'
import net from '#node:net'

/**
 * Probe `dir`'s filesystem. Returns a report; never throws for a merely
 * unsupported capability (only for an unwritable dir).
 *
 * @param {string} dir  a path on the filesystem to test (created if absent)
 * @returns {Promise<{ok:boolean, hardlink:boolean, symlink:boolean,
 *   unixSocket:boolean, onWindowsDrive:boolean, errors:string[]}>}
 */
export async function checkFilesystem (dir) {
  const errors = []
  const result = {
    ok: false,
    hardlink: false,
    symlink: false,
    unixSocket: false,
    onWindowsDrive: false,
    errors
  }

  fs.mkdirSync(dir, { recursive: true })
  const scratch = fs.mkdtempSync(path.join(dir, '.pgh-fscheck-'))

  try {
    // WSL Windows-drive detection: /mnt/<letter>/ is drvfs/9p, not a real
    // Linux fs. Resolve symlinks first so a symlinked store is caught too.
    let real = scratch
    try { real = fs.realpathSync(scratch) } catch {}
    if (/^\/mnt\/[a-z](\/|$)/i.test(real)) result.onWindowsDrive = true

    // hardlink — SOFT (copy fallback exists; dedup lost without it)
    try {
      const a = path.join(scratch, 'a')
      fs.writeFileSync(a, 'x')
      fs.linkSync(a, path.join(scratch, 'b'))
      result.hardlink = true
    } catch {
      result.hardlink = false
    }

    // symlink — HARD (node_modules wiring has no fallback)
    try {
      fs.symlinkSync('a', path.join(scratch, 's'))
      result.symlink = true
    } catch (err) {
      errors.push('symlinks not supported (' + (err.code || err.message) + ')')
    }

    // unix domain socket — HARD (sidecar + daemon UDS)
    result.unixSocket = await canBindUnixSocket(path.join(scratch, 'x.sock'))
    if (!result.unixSocket) errors.push('unix domain sockets not supported')
  } finally {
    try { fs.rmSync(scratch, { recursive: true, force: true }) } catch {}
  }

  result.ok = result.symlink && result.unixSocket
  return result
}

/** Try to bind an AF_UNIX server at `sockPath`. Resolves true/false, never
 *  rejects. Guarded by a timeout so a wedged fs can't hang init. */
function canBindUnixSocket (sockPath) {
  return new Promise((resolve) => {
    let done = false
    let server = null
    const finish = (ok) => {
      if (done) return
      done = true
      if (server) { try { server.close() } catch {} }
      resolve(ok)
    }

    try {
      server = net.createServer()
    } catch {
      return finish(false)
    }
    server.on('error', () => finish(false))
    try {
      server.listen(sockPath, () => finish(true))
    } catch {
      return finish(false)
    }

    const t = setTimeout(() => finish(false), 3000)
    if (t && typeof t.unref === 'function') t.unref()
  })
}

/** Human-readable one-liner block for the preflight report. */
export function formatReport (r, dir) {
  const yn = (b) => (b ? 'ok' : 'NO')
  const lines = [
    '  filesystem preflight (' + dir + '):',
    '    unix sockets: ' + yn(r.unixSocket) + (r.unixSocket ? '' : '  <- required'),
    '    symlinks:     ' + yn(r.symlink) + (r.symlink ? '' : '  <- required'),
    '    hardlinks:    ' + yn(r.hardlink) + (r.hardlink ? '' : '  (dedup disabled — copies instead)')
  ]
  if (r.onWindowsDrive) {
    lines.push('    WARNING: store is on a Windows drive (/mnt) — move it to $HOME on the Linux filesystem (WSL2).')
  }
  return lines.join('\n')
}