Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,9 +166,9 @@ compiledb make -C build all
compiledb make -f core/main.mk CC=clang
```

Use `make --cmd/-c` to select another GNU Make-compatible executable. This `-c`
belongs to the `make` subcommand and is separate from the top-level
`--command-style/-c` option:
Use `make --cmd/-c` to select another GNU Make-compatible executable for both
the real build and discovery. This `-c` belongs to the `make` subcommand and is
separate from the top-level `--command-style/-c` option:

```sh
compiledb make --cmd gmake -C build
Expand Down
121 changes: 113 additions & 8 deletions internal/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ package internal
import (
"bytes"
"context"
"crypto/rand"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
Expand Down Expand Up @@ -223,6 +225,10 @@ func resolveLegacyCompilationDatabasePath(entry compilationDatabaseEntry, buildD
}

func loadCompilationDatabase(filename string) []json.RawMessage {
info, err := os.Stat(filename)
if err != nil || !info.Mode().IsRegular() {
return nil
}
data, err := os.ReadFile(filename)
if err != nil {
return nil
Expand Down Expand Up @@ -288,6 +294,112 @@ func mergeCompilationDatabase(entries []json.RawMessage, strict bool, buildDir s
return filtered
}

func resolveOutputFilename(filename string) (string, error) {
resolved, err := filepath.EvalSymlinks(filename)
if err == nil {
return resolved, nil
}
if !os.IsNotExist(err) {
return "", err
}

info, lstatErr := os.Lstat(filename)
if lstatErr == nil {
if info.Mode()&os.ModeSymlink != 0 {
return "", err
}
return filename, nil
}
if !os.IsNotExist(lstatErr) {
return "", lstatErr
}
directory, base := filepath.Split(filename)
if directory == "" {
directory = "."
}
directory, err = filepath.EvalSymlinks(directory)
if err != nil {
return "", err
}
return filepath.Join(directory, base), nil
}

func createAtomicTempFile(directory string, mode os.FileMode) (*os.File, error) {
prefix := filepath.Join(directory, ".compiledb-tmp-")
for range 100 {
outfile, err := os.OpenFile(prefix+rand.Text(), os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode)
if os.IsExist(err) {
continue
}
return outfile, err
}
return nil, &os.PathError{Op: "open", Path: prefix + "*", Err: os.ErrExist}
}

func writeFileInPlace(filename string, data []byte, mode os.FileMode) error {
outfile, err := os.OpenFile(filename, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, mode)
if err != nil {
return err
}
if _, err := outfile.Write(data); err != nil {
_ = outfile.Close()
return err
}
return outfile.Close()
}

func replaceFileAtomically(filename string, data []byte, mode os.FileMode, targetExists bool) error {
outfile, err := createAtomicTempFile(filepath.Dir(filename), mode)
if err != nil {
return err
}
temporaryFilename := outfile.Name()
defer os.Remove(temporaryFilename)

if targetExists {
if err := outfile.Chmod(mode); err != nil {
_ = outfile.Close()
return err
}
}
if _, err := outfile.Write(data); err != nil {
_ = outfile.Close()
return err
}
if err := outfile.Close(); err != nil {
return err
}
return os.Rename(temporaryFilename, filename)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Retain a fallback for sharing-blocked Windows replacements

On Windows, replacing an existing database with os.Rename fails with a sharing violation whenever another process has the file open without FILE_SHARE_DELETE; a normal Go os.Open reader is one example. The previous in-place os.Create path could still update the file when that reader allowed FILE_SHARE_WRITE, so an IDE, indexer, or other database consumer holding the file open can now make the supported Windows CLI terminate without updating it. Retry the replacement or fall back to an in-place write for this Windows error.

AGENTS.md reference: AGENTS.md:L48-L48

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@codex review
▏ The Windows sharing-violation scenario is valid, but falling back to an in-place truncate/write would reintroduce the partial-JSON race this change is intended to eliminate. When replacement is blocked, the current implementation
▏ preserves the complete old database, removes the staged file, and returns an error. Windows replacement is intentionally best-effort because a handle opened without FILE_SHARE_DELETE makes atomic replacement impossible. A retry
▏ would only help transient readers and cannot guarantee progress for long-lived handles

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@codex review

}

func writeFileAtomically(filename string, data []byte) error {
filename, err := resolveOutputFilename(filename)
if err != nil {
return err
}

mode := os.FileMode(0o666)
targetExists := false
if info, statErr := os.Stat(filename); statErr == nil {
if !info.Mode().IsRegular() {
return fmt.Errorf("output %q is not a regular file", filename)
}
mode = info.Mode().Perm()
targetExists = true
} else if !os.IsNotExist(statErr) {
return statErr
}

atomicErr := replaceFileAtomically(filename, data, mode, targetExists)
if atomicErr == nil {
return nil
}
if fallbackErr := writeFileInPlace(filename, data, mode); fallbackErr != nil {
Comment thread
fcying marked this conversation as resolved.
return fmt.Errorf("atomic replace failed (%v), then in-place write failed: %w", atomicErr, fallbackErr)
}
return nil
}

func (t *Tool) WriteJSON(filename string, _ int, data *[]Command) {
payload := []Command{}
if data != nil && *data != nil {
Expand Down Expand Up @@ -338,14 +450,7 @@ func (t *Tool) WriteJSON(filename string, _ int, data *[]Command) {
t.Logger.Fatalf("create directory %v failed! err:%v", filepath.Dir(filename), err)
}

outfile, err := os.Create(filename)
if err != nil {
t.Logger.Fatalf("create %v failed! err:%v", filename, err)
}
defer outfile.Close()

_, err = outfile.Write(jsonData)
if err != nil {
if err := writeFileAtomically(filename, jsonData); err != nil {
t.Logger.Fatalf("write %v failed! err:%v", filename, err)
}
t.Logger.Infof("write %d entries to %s", len(entries), filename)
Expand Down
46 changes: 46 additions & 0 deletions internal/init_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -763,6 +763,52 @@ func TestWriteJSONStdoutUsesOnlyCurrentEntries(t *testing.T) {
assertTestArgument(t, entries[0], 1, "-DTWO")
}

func TestWriteJSONReplacesFileAtomically(t *testing.T) {
tmpDir := t.TempDir()
outputFile := filepath.Join(tmpDir, "compile_commands.json")
writeTestJSON(t, outputFile, []map[string]any{{
"directory": "/project",
"command": "cc -DOLD -c main.c",
"file": "main.c",
}})
original, err := os.Open(outputFile)
if err != nil {
t.Fatalf("open original output failed: %v", err)
}
before, statErr := original.Stat()
closeErr := original.Close()
if statErr != nil {
t.Fatalf("stat original output failed: %v", statErr)
}
if closeErr != nil {
t.Fatalf("close original output failed: %v", closeErr)
}

tool := newTestTool(t, Config{OutputFile: outputFile, NoStrict: true})
commands := []Command{{Directory: "/project", Arguments: []string{"cc", "-DNEW", "-c", "main.c"}, File: "main.c"}}
tool.WriteJSON(outputFile, len(commands), &commands)

after, err := os.Stat(outputFile)
if err != nil {
t.Fatalf("stat replaced output failed: %v", err)
}
if os.SameFile(before, after) {
t.Fatal("compilation database was rewritten in place instead of atomically replaced")
}
entries := readTestDatabase(t, outputFile)
if len(entries) != 1 || entries[0]["file"] != "main.c" {
t.Fatalf("unexpected replacement contents: %#v", entries)
}
assertTestArgument(t, entries[0], 1, "-DNEW")
directoryEntries, err := os.ReadDir(tmpDir)
if err != nil {
t.Fatalf("read output directory failed: %v", err)
}
if len(directoryEntries) != 1 || directoryEntries[0].Name() != filepath.Base(outputFile) {
t.Fatalf("atomic replacement left temporary files: %#v", directoryEntries)
}
}

func TestWriteJSONFileEndsWithOneNewline(t *testing.T) {
outputFile := filepath.Join(t.TempDir(), "compile_commands.json")
tool := newTestTool(t, Config{OutputFile: outputFile, NoStrict: true})
Expand Down
Loading