Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions cli/cmd/prune.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,13 +63,13 @@ var pruneCmd = &cobra.Command{
Identifies artifacts that are no longer needed:
- Old versions of analyzer JARs, autobuilder JARs, and rules
- Downloaded JDK/JRE versions that don't match the current version
- Cached project models and staging directories
- Cached project models

Use category flags to prune selectively:
--artifacts Stale analyzer and autobuilder JARs
--rules Stale rules directories
--jdk Old JDK/JRE versions
--models Cached project models and staging directories
--models Cached project models
--logs Project log files
--install Install-tier lib and JRE artifacts (requires re-download)

Expand All @@ -86,7 +86,7 @@ With --all: prunes everything including logs and install-tier.`,
if err != nil {
out.Fatalf("Failed to resolve prune lock path: %s", err)
}
pruneLock, err := utils.TryLock(pruneLockPath, utils.LockMeta{
pruneLock, err := utils.TryLockExclusive(pruneLockPath, utils.LockMeta{
PID: os.Getpid(),
Command: "prune",
})
Expand Down Expand Up @@ -158,7 +158,7 @@ func init() {
pruneCmd.Flags().BoolVar(&pruneArtifacts, "artifacts", false, "Prune stale analyzer and autobuilder JARs")
pruneCmd.Flags().BoolVar(&pruneRules, "rules", false, "Prune stale rules directories")
pruneCmd.Flags().BoolVar(&pruneJDK, "jdk", false, "Prune old JDK/JRE versions")
pruneCmd.Flags().BoolVar(&pruneModels, "models", false, "Prune cached project models and staging directories")
pruneCmd.Flags().BoolVar(&pruneModels, "models", false, "Prune cached project models")
pruneCmd.Flags().BoolVar(&pruneLogs, "logs", false, "Prune project log files")
pruneCmd.Flags().BoolVar(&pruneInstall, "install", false, "Prune install-tier lib and JRE artifacts (requires re-download on next run)")
}
104 changes: 58 additions & 46 deletions cli/cmd/scan.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,11 +65,10 @@ func (m ScanMode) String() string {
// scanConfig holds the resolved paths and flags for a scan invocation.
type scanConfig struct {
mode ScanMode
absProjectModel string // absolute path to the project model (cached or staging)
absProjectModel string // absolute path to the project model (always the cache dir when projectCachePath is set)
projectCachePath string // cache dir for this project (empty for explicit model / dry-run)
stagingDir string // staging dir path (empty when not compiling or dry-run)
needsCompilation bool // true when compilation is needed before scanning
compileLock *utils.FileLock
cacheLock *utils.FileLock
}

// scanCmd represents the scan command
Expand Down Expand Up @@ -161,8 +160,8 @@ func scan(cmd *cobra.Command) {

cfg := resolveScanConfig(absUserProjectRoot)
defer func() {
if cfg.compileLock != nil {
cfg.compileLock.Unlock()
if cfg.cacheLock != nil {
cfg.cacheLock.Unlock()
}
}()

Expand All @@ -173,12 +172,6 @@ func scan(cmd *cobra.Command) {

absProjectModelPath := cfg.absProjectModel

cleanupStaging := func() {
if cfg.stagingDir != "" {
utils.CleanupStagingDir(cfg.stagingDir)
}
}

var absRuleSetPaths []RulesetType
var userRuleSetPath = Ruleset

Expand Down Expand Up @@ -278,31 +271,35 @@ func scan(cmd *cobra.Command) {
out.Fatalf("Failed to resolve Java for compilation: %s", err)
}

// Wipe any residue from a prior crashed compile before writing new output.
if cfg.projectCachePath != "" {
if err := os.RemoveAll(cfg.absProjectModel); err != nil {
out.Fatalf("Failed to prepare cache directory: %s", err)
}
}

if err := out.RunWithSpinner("Compiling project model", func() error {
return compile(absUserProjectRoot, cfg.absProjectModel, autobuilderJarPath, compileJavaRunner, Internal)
}); err != nil {
cleanupStaging()
if cfg.projectCachePath != "" {
_ = os.RemoveAll(cfg.absProjectModel)
}
out.Error("Native compile has failed: " + err.Error())
suggest("If native compilation fails due to missing required Java, set JAVA_HOME according to the project's requirements or try Docker-based scan:", utils.BuildScanCommandWithDocker(currentScanBuilder(""), absUserProjectRoot, absSarifReportPath, Ruleset))
os.Exit(1)
}
out.Blank()

// Promote staging to cache
// Mark the cache as valid, then downgrade to a reader so other scans
// can run the analyzer against the freshly-compiled model in parallel.
if cfg.projectCachePath != "" {
if err := utils.PromoteStagingToCache(cfg.projectCachePath, cfg.stagingDir); err != nil {
output.LogInfof("Failed to promote staging to cache: %v", err)
} else {
cfg.stagingDir = "" // staging dir no longer exists
absProjectModelPath = utils.CachedProjectModelPath(cfg.projectCachePath)
output.LogDebugf("Model cached at: %s", absProjectModelPath)
if err := utils.MarkCompileComplete(cfg.projectCachePath); err != nil {
_ = os.RemoveAll(cfg.absProjectModel)
out.Fatalf("Failed to mark model complete: %s", err)
}
if err := cfg.cacheLock.Downgrade(); err != nil {
output.LogInfof("Cache lock downgrade failed, continuing under exclusive: %v", err)
}
}

// Recompute SARIF path against the promoted model path
if SarifReportPath == "" {
absSarifReportPath = utils.DefaultSarifReportPath(absProjectModelPath)
sarifReportName = filepath.Base(absSarifReportPath)
}

printCompileSummary(absProjectModelPath)
Expand Down Expand Up @@ -358,7 +355,6 @@ func scan(cmd *cobra.Command) {
if err := out.RunWithSpinner("Analyzing project", func() error {
return scanProject(nativeBuilder, analyzerJavaRunner)
}); err != nil {
cleanupStaging()
out.Fatalf("Native scan has failed: %s", err)
}

Expand Down Expand Up @@ -413,42 +409,58 @@ func resolveScanConfig(absUserProjectRoot string) scanConfig {
}

cachedModelPath := utils.CachedProjectModelPath(projectCachePath)
if !Recompile {
if _, serr := os.Stat(filepath.Join(cachedModelPath, "project.yaml")); serr == nil {
output.LogDebugf("Reusing cached model at: %s", cachedModelPath)
return scanConfig{
mode: Scan,
absProjectModel: cachedModelPath,
projectCachePath: projectCachePath,
cacheLockPath := utils.CacheLockPath(projectCachePath)

// Fast path: if we're not forced to recompile and the cache looks
// complete on disk, take a shared lock and re-check under the lock.
if !Recompile && utils.IsCachedModelComplete(projectCachePath) {
sharedLock, sharedErr := utils.TryLockShared(cacheLockPath)
if sharedErr == nil {
if utils.IsCachedModelComplete(projectCachePath) {
output.LogDebugf("Reusing cached model at: %s", cachedModelPath)
return scanConfig{
mode: Scan,
absProjectModel: cachedModelPath,
projectCachePath: projectCachePath,
cacheLock: sharedLock,
}
}
// Marker vanished between the outer check and the lock
// (writer raced ahead of us). Fall through to compile path.
sharedLock.Unlock()
} else if sharedErr != utils.ErrLocked {
out.Fatalf("Failed to acquire cache read lock: %s", sharedErr)
}
// sharedErr == ErrLocked means a writer holds the cache; we're about
// to ask for exclusive below, which will also fail with ErrLocked —
// ReadLockMeta below will surface which command is holding it.
}

compileLock, lockErr := utils.TryLock(
utils.CompileLockPath(projectCachePath),
cacheLock, lockErr := utils.TryLockExclusive(
cacheLockPath,
utils.LockMeta{PID: os.Getpid(), Command: "compile", Project: absUserProjectRoot},
)
if lockErr == utils.ErrLocked {
out.Error("Compilation already in progress for this project")
// Readers don't stamp metadata (empty LockMeta); writers do. Use that
// to distinguish an in-progress compile from an in-progress analyze.
if meta, _ := utils.ReadLockMeta(cacheLockPath); meta.PID != 0 {
out.Error("Compilation already in progress for this project")
} else {
out.Error("Another scan is currently analyzing this project")
}
suggest("To scan an existing model instead", utils.NewScanCommand("").WithProjectModel("<model-path>").Build())
os.Exit(1)
}
if lockErr != nil {
out.Fatalf("Failed to acquire compile lock: %s", lockErr)
}

stagingDir, serr := utils.CreateStagingDir(projectCachePath)
if serr != nil {
out.Fatalf("Failed to create staging directory: %s", serr)
out.Fatalf("Failed to acquire cache lock: %s", lockErr)
}

return scanConfig{
mode: CompileAndScan,
absProjectModel: filepath.Join(stagingDir, "project-model"),
absProjectModel: cachedModelPath,
projectCachePath: projectCachePath,
stagingDir: stagingDir,
needsCompilation: true,
compileLock: compileLock,
cacheLock: cacheLock,
}
}

Expand All @@ -462,7 +474,7 @@ func printScanInfo(cmd *cobra.Command, cfg scanConfig, absSemgrepRuleLoadTracePa
if cfg.needsCompilation {
sb.Field("Project", absUserProjectRoot)
if cfg.projectCachePath != "" {
sb.Field("Project model", utils.CachedProjectModelPath(cfg.projectCachePath))
sb.Field("Project model", cfg.absProjectModel)
}
} else {
sb.Field("Project model", cfg.absProjectModel)
Expand Down
107 changes: 84 additions & 23 deletions cli/internal/utils/lock.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,40 +11,38 @@ import (
"github.com/gofrs/flock"
)

// ErrLocked is returned when a lock file is already held by another process.
// ErrLocked is returned when a lock file is already held by another process
// in an incompatible mode.
var ErrLocked = errors.New("lock is held by another process")

// LockMeta holds diagnostic information written into lock files.
// LockMeta holds diagnostic information written into lock files by exclusive
// (writer) holders. It is zero for shared (reader) holders.
type LockMeta struct {
PID int
Command string
Project string
}

// FileLock wraps a flock.Flock with its path for cleanup.
// FileLock wraps a flock.Flock with its path and the mode it was acquired in.
type FileLock struct {
flock *flock.Flock
path string
flock *flock.Flock
path string
exclusive bool
}

// Unlock releases the advisory lock and removes the lock file.
func (l *FileLock) Unlock() {
_ = l.flock.Unlock()
_ = os.Remove(l.path)
}

// TryLock attempts a non-blocking exclusive lock on the given path.
// On success it writes meta into the file and returns a FileLock.
// On failure because the lock is held, it returns ErrLocked.
func TryLock(lockPath string, meta LockMeta) (*FileLock, error) {
// TryLockExclusive attempts a non-blocking LOCK_EX on the given path. On
// success it stamps meta into the file and returns a FileLock whose Unlock
// will clear the file content before releasing the kernel lock. On failure
// because another exclusive or shared holder exists, returns ErrLocked.
func TryLockExclusive(lockPath string, meta LockMeta) (*FileLock, error) {
if err := os.MkdirAll(filepath.Dir(lockPath), 0o755); err != nil {
return nil, fmt.Errorf("failed to create lock directory: %w", err)
}

fl := flock.New(lockPath)
locked, err := fl.TryLock()
if err != nil {
return nil, fmt.Errorf("failed to acquire lock: %w", err)
return nil, fmt.Errorf("failed to acquire exclusive lock: %w", err)
}
if !locked {
return nil, ErrLocked
Expand All @@ -56,10 +54,73 @@ func TryLock(lockPath string, meta LockMeta) (*FileLock, error) {
}
_ = os.WriteFile(lockPath, []byte(content), 0o644)

return &FileLock{flock: fl, path: lockPath}, nil
return &FileLock{flock: fl, path: lockPath, exclusive: true}, nil
}

// TryLockShared attempts a non-blocking LOCK_SH on the given path. Multiple
// shared holders can coexist. Returns ErrLocked if an exclusive holder is
// present. The file content is not modified.
func TryLockShared(lockPath string) (*FileLock, error) {
if err := os.MkdirAll(filepath.Dir(lockPath), 0o755); err != nil {
return nil, fmt.Errorf("failed to create lock directory: %w", err)
}

fl := flock.New(lockPath)
locked, err := fl.TryRLock()
if err != nil {
return nil, fmt.Errorf("failed to acquire shared lock: %w", err)
}
if !locked {
return nil, ErrLocked
}

return &FileLock{flock: fl, path: lockPath, exclusive: false}, nil
}

// Downgrade atomically converts a held LOCK_EX to LOCK_SH on the same fd and
// truncates the metadata file. After Downgrade the handle may still be
// released by Unlock. Calling Downgrade on a shared handle returns an error.
//
// Metadata is truncated *before* the kernel mode transition so that any
// concurrent prune that fails to acquire exclusive can never see stale
// writer PID under a shared lock. If the truncate fails the kernel mode
// is not touched and the handle remains exclusively held — the caller can
// continue as if Downgrade were never called.
func (l *FileLock) Downgrade() error {
if !l.exclusive {
return errors.New("downgrade called on non-exclusive lock")
}
if err := os.Truncate(l.path, 0); err != nil {
return fmt.Errorf("failed to clear lock metadata on downgrade: %w", err)
}
// gofrs/flock.RLock on a held exclusive fd issues unix.Flock(fd, LOCK_SH)
// on the same descriptor, which is an atomic downgrade on POSIX. After
// this call, gofrs internally has both its f.l and f.r fields set to
// true; that is harmless because its Unlock path issues LOCK_UN whenever
// either flag is set.
if err := l.flock.RLock(); err != nil {
return fmt.Errorf("failed to downgrade lock: %w", err)
}
l.exclusive = false
return nil
}

// Unlock releases the advisory lock. For exclusive holders it first truncates
// the metadata so prune's diagnostic output does not show a stale writer PID.
// The lock file itself is not removed — removing it while other shared
// holders still have it open would let a subsequent acquirer create a new
// inode and silently bypass mutual exclusion.
func (l *FileLock) Unlock() {
if l.exclusive {
_ = os.Truncate(l.path, 0)
l.exclusive = false
}
_ = l.flock.Unlock()
}

// ReadLockMeta reads diagnostic metadata from a lock file.
// ReadLockMeta reads diagnostic metadata from a lock file. Returns an empty
// LockMeta when the file exists but has no content (shared holder, or after
// an exclusive holder's Unlock/Downgrade).
func ReadLockMeta(lockPath string) (LockMeta, error) {
data, err := os.ReadFile(lockPath)
if err != nil {
Expand All @@ -83,7 +144,7 @@ func ReadLockMeta(lockPath string) (LockMeta, error) {
return meta, nil
}

// PruneLockPath returns the path to the global prune lock: ~/.opentaint/.prune.lock
// PruneLockPath returns ~/.opentaint/.prune.lock.
func PruneLockPath() (string, error) {
home, err := GetOpenTaintHomePath()
if err != nil {
Expand All @@ -92,8 +153,8 @@ func PruneLockPath() (string, error) {
return filepath.Join(home, ".prune.lock"), nil
}

// CompileLockPath returns the path to a per-project compile lock:
// <projectCachePath>/.compile.lock
func CompileLockPath(projectCachePath string) string {
return filepath.Join(projectCachePath, ".compile.lock")
// CacheLockPath returns <projectCachePath>/.cache.lock — the reader/writer
// lock gating access to the compiled project model.
func CacheLockPath(projectCachePath string) string {
return filepath.Join(projectCachePath, ".cache.lock")
}
Loading
Loading