Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1a85ec5a2 | ||
|
|
e5efc22f75 | ||
|
|
48f21d4ecf | ||
|
|
f8c437b83f | ||
|
|
04093f53ad |
@@ -0,0 +1,4 @@
|
|||||||
|
# Every PR adds an entry at the top of TODO.md's Completed Steps; union keeps
|
||||||
|
# both sides instead of conflicting. Git never reports a conflict here: read
|
||||||
|
# the merged entries after every merge or rebase.
|
||||||
|
TODO.md merge=union
|
||||||
@@ -413,10 +413,10 @@ pixa finds: `/etc/pixa/config.yml`, `/etc/pixa/config.yaml`,
|
|||||||
`~/.config/pixa/config.yml`, `~/.config/pixa/config.yaml`, then `config.yml`
|
`~/.config/pixa/config.yml`, `~/.config/pixa/config.yaml`, then `config.yml`
|
||||||
and `config.yaml` in the working directory. A named file that does not exist,
|
and `config.yaml` in the working directory. A named file that does not exist,
|
||||||
cannot be read or does not parse aborts startup. Of the files pixa looks for on
|
cannot be read or does not parse aborts startup. Of the files pixa looks for on
|
||||||
its own, one it finds but cannot read or parse aborts startup; one it cannot
|
its own, only one that does not exist is passed over, without a message. One
|
||||||
find, for any reason, is passed over without a message, even when the file is
|
that pixa cannot read or parse aborts startup, naming the file. So does one in a
|
||||||
there in a directory pixa may not enter. With no file, pixa uses the environment
|
directory pixa may not enter, whether or not it is there, since pixa cannot
|
||||||
and the defaults.
|
tell. With no file, pixa uses the environment and the defaults.
|
||||||
|
|
||||||
| Variable | Config key | Meaning |
|
| Variable | Config key | Meaning |
|
||||||
| ------------------------------------ | ------------------------------- | ---------------------------------------------------------------------------- |
|
| ------------------------------------ | ------------------------------- | ---------------------------------------------------------------------------- |
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
* branch per issue from `next`
|
* branch per issue from `next`
|
||||||
* do the work in Next Step
|
* do the work in Next Step
|
||||||
* move Next Step to the top of Completed Steps
|
* move Next Step to the top of Completed Steps
|
||||||
|
* `TODO.md` merges with git's union merge (`.gitattributes`), which never
|
||||||
|
reports a conflict: read the merged entries after every merge or rebase
|
||||||
* move the top item of Future Steps into Next Step
|
* move the top item of Future Steps into Next Step
|
||||||
* commit (`TODO.md` changes in the same commit as the work)
|
* commit (`TODO.md` changes in the same commit as the work)
|
||||||
* open a PR based on `next`
|
* open a PR based on `next`
|
||||||
@@ -29,6 +31,28 @@ P2: security: referer blacklist
|
|||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
|
- 2026-10-04 `TODO.md` merges with git's union merge (closes #190): a root
|
||||||
|
`.gitattributes`, copied from `sneak/prompts`, marks it `merge=union`, so two
|
||||||
|
branches that each add an entry at the top of Completed Steps merge without a
|
||||||
|
conflict and keep both entries. Git now never reports a conflict in
|
||||||
|
`TODO.md`: a real one keeps both versions of the lines, and two entries that
|
||||||
|
share an identical line can end up one inside the other, which a rebase can
|
||||||
|
do to an entry already on `next`. The Workflow above says to read the merged
|
||||||
|
entries after every merge or rebase.
|
||||||
|
- 2026-10-04 `TestEvictionRunsOnPeriodicSchedule` no longer races the evictor
|
||||||
|
(closes #183): it wrote each variant file and then inserted its accounting row
|
||||||
|
by hand, and a reconciliation pass between the two adopted the file first, so
|
||||||
|
the insert failed. It now writes the files only, while holding the test
|
||||||
|
database's only connection so the evictor's startup pass waits after walking
|
||||||
|
the empty variant directory; a periodic reconciliation pass then adopts the
|
||||||
|
files and the eviction pass after it evicts them. No other test in
|
||||||
|
`internal/imgcache` inserts a row by hand after starting the evictor. Test
|
||||||
|
only.
|
||||||
|
- 2026-10-04 a config file pixa cannot read aborts startup (closes #176): of the
|
||||||
|
places pixa looks for its config file on its own, only one where the file does
|
||||||
|
not exist is passed over; any other error, such as a directory on the path
|
||||||
|
that pixa may not enter, aborts startup naming the file, as a file that does
|
||||||
|
not parse already did.
|
||||||
- 2026-10-04 `.golangci.yml` re-vendored from the canonical copy (closes #57):
|
- 2026-10-04 `.golangci.yml` re-vendored from the canonical copy (closes #57):
|
||||||
the deprecated `gomodguard` is switched off, so lint runs print no
|
the deprecated `gomodguard` is switched off, so lint runs print no
|
||||||
deprecation warning; its successor `gomodguard_v2` runs with the shared
|
deprecation warning; its successor `gomodguard_v2` runs with the shared
|
||||||
|
|||||||
+21
-11
@@ -4,6 +4,7 @@ package config
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"math"
|
"math"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
@@ -14,6 +15,7 @@ import (
|
|||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/smartconfig"
|
"git.eeqj.de/sneak/smartconfig"
|
||||||
@@ -778,19 +780,27 @@ func loadConfigFile(log *slog.Logger, appName string) (*smartconfig.Config, erro
|
|||||||
for _, path := range configPaths {
|
for _, path := range configPaths {
|
||||||
cleanPath := filepath.Clean(path)
|
cleanPath := filepath.Clean(path)
|
||||||
|
|
||||||
|
// Only a config file that does not exist is skipped, including
|
||||||
|
// one whose path runs through a file, such as under a HOME of
|
||||||
|
// /dev/null. One that cannot be read or does not parse is a
|
||||||
|
// fatal startup error.
|
||||||
_, statErr := os.Stat(cleanPath)
|
_, statErr := os.Stat(cleanPath)
|
||||||
if statErr == nil {
|
if errors.Is(statErr, fs.ErrNotExist) || errors.Is(statErr, syscall.ENOTDIR) {
|
||||||
// A config file that exists but does not parse is a fatal
|
continue
|
||||||
// startup error, never something to skip over.
|
|
||||||
sc, err := smartconfig.NewFromConfigPath(path)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to parse config file %s: %w", path, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Info("loaded config file", "path", path)
|
|
||||||
|
|
||||||
return sc, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if statErr != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read config file %s: %w", path, statErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
sc, err := smartconfig.NewFromConfigPath(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse config file %s: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Info("loaded config file", "path", path)
|
||||||
|
|
||||||
|
return sc, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, nil //nolint:nilnil // nil config is valid (use defaults)
|
return nil, nil //nolint:nilnil // nil config is valid (use defaults)
|
||||||
|
|||||||
@@ -564,6 +564,116 @@ func TestMalformedConfigFileAbortsStartup(t *testing.T) {
|
|||||||
t.Logf("got expected error: %v", err)
|
t.Logf("got expected error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestConfigFileInDirectoryPixaMayNotEnterAbortsStartup checks that a
|
||||||
|
// config file pixa cannot read because it may not enter its directory
|
||||||
|
// aborts startup instead of being passed over.
|
||||||
|
func TestConfigFileInDirectoryPixaMayNotEnterAbortsStartup(t *testing.T) {
|
||||||
|
if os.Geteuid() == 0 {
|
||||||
|
t.Skip("root may enter any directory")
|
||||||
|
}
|
||||||
|
|
||||||
|
home := t.TempDir()
|
||||||
|
configDir := filepath.Join(home, ".config", "pixa-test-nonexistent-app")
|
||||||
|
configPath := filepath.Join(configDir, "config.yml")
|
||||||
|
|
||||||
|
err := os.MkdirAll(configDir, 0o700)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create config directory: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = os.WriteFile(configPath, []byte(signingKeyLine), 0o600)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to write config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = os.Chmod(configDir, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to remove the config directory's permissions: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Give the directory back its permissions so t.TempDir can remove it.
|
||||||
|
t.Cleanup(func() {
|
||||||
|
//nolint:gosec // G302: a directory needs its execute bit to be removed
|
||||||
|
_ = os.Chmod(configDir, 0o700)
|
||||||
|
})
|
||||||
|
|
||||||
|
// The ~/.config candidate is the only one that exists: the appname
|
||||||
|
// rules out /etc, and the working directory is empty.
|
||||||
|
t.Setenv("PIXA_CONFIG_PATH", "")
|
||||||
|
t.Setenv("HOME", home)
|
||||||
|
t.Chdir(t.TempDir())
|
||||||
|
|
||||||
|
log := slog.New(slog.DiscardHandler)
|
||||||
|
|
||||||
|
sc, err := loadConfigFile(log, "pixa-test-nonexistent-app")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("config file pixa cannot read must abort startup, got config: %v",
|
||||||
|
sc)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("got expected error: %v", err)
|
||||||
|
|
||||||
|
if !strings.Contains(err.Error(), configPath) {
|
||||||
|
t.Errorf("error %q does not name the config file %s", err.Error(), configPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConfigFileLinkingToItselfAbortsStartup checks that a config file
|
||||||
|
// pixa cannot read for a reason other than not existing aborts startup,
|
||||||
|
// as root too: a symbolic link to itself fails with "too many levels of
|
||||||
|
// symbolic links".
|
||||||
|
func TestConfigFileLinkingToItselfAbortsStartup(t *testing.T) {
|
||||||
|
workDir := t.TempDir()
|
||||||
|
|
||||||
|
err := os.Symlink("config.yml", filepath.Join(workDir, "config.yml"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create symbolic link: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only the working directory's config.yml is there: the appname rules
|
||||||
|
// out /etc, and HOME is empty.
|
||||||
|
t.Setenv("PIXA_CONFIG_PATH", "")
|
||||||
|
t.Setenv("HOME", t.TempDir())
|
||||||
|
t.Chdir(workDir)
|
||||||
|
|
||||||
|
log := slog.New(slog.DiscardHandler)
|
||||||
|
|
||||||
|
sc, err := loadConfigFile(log, "pixa-test-nonexistent-app")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("config file pixa cannot read must abort startup, got config: %v",
|
||||||
|
sc)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("got expected error: %v", err)
|
||||||
|
|
||||||
|
if !strings.Contains(err.Error(), "config.yml") {
|
||||||
|
t.Errorf("error %q does not name the config file config.yml", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConfigPathThroughFileIsPassedOver checks that a config file path
|
||||||
|
// that runs through a file, such as one under a HOME of /dev/null, is
|
||||||
|
// passed over like one that does not exist, since no file can be there.
|
||||||
|
func TestConfigPathThroughFileIsPassedOver(t *testing.T) {
|
||||||
|
// No config file is there: the appname rules out /etc, HOME is
|
||||||
|
// /dev/null, and the working directory is empty.
|
||||||
|
t.Setenv("PIXA_CONFIG_PATH", "")
|
||||||
|
t.Setenv("HOME", os.DevNull)
|
||||||
|
t.Chdir(t.TempDir())
|
||||||
|
|
||||||
|
log := slog.New(slog.DiscardHandler)
|
||||||
|
|
||||||
|
sc, err := loadConfigFile(log, "pixa-test-nonexistent-app")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("a config path through a file must be passed over, got error: %v",
|
||||||
|
err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if sc != nil {
|
||||||
|
t.Errorf("expected no config file, got config: %v", sc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestEnsureStateDirCreatesDirectory(t *testing.T) {
|
func TestEnsureStateDirCreatesDirectory(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -681,6 +681,11 @@ func TestEvictionRunsUnderWritePressure(t *testing.T) {
|
|||||||
assertNoDanglingReferences(t, cache)
|
assertNoDanglingReferences(t, cache)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestEvictionRunsOnPeriodicSchedule writes three variant files straight
|
||||||
|
// to disk, bypassing StoreVariant, so they have no accounting rows and no
|
||||||
|
// write-pressure notification fires. Only a periodic reconciliation pass
|
||||||
|
// can then adopt them, and only the eviction pass that follows it can
|
||||||
|
// evict them.
|
||||||
func TestEvictionRunsOnPeriodicSchedule(t *testing.T) {
|
func TestEvictionRunsOnPeriodicSchedule(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -688,13 +693,29 @@ func TestEvictionRunsOnPeriodicSchedule(t *testing.T) {
|
|||||||
|
|
||||||
cache, _ := newEvictionTestCache(t, limit)
|
cache, _ := newEvictionTestCache(t, limit)
|
||||||
|
|
||||||
// Start the evictor while the cache is empty, then create tracked
|
// Hold the test database's only connection, so the startup pass
|
||||||
// over-limit state WITHOUT going through the store methods, so no
|
// waits for it after walking the still empty variant directory: the
|
||||||
// write-pressure notification fires and only the periodic ticker
|
// files written while it waits are first seen by a periodic pass.
|
||||||
// can trigger eviction.
|
conn, err := cache.db.Conn(t.Context())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to take the database connection: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = conn.Close() }()
|
||||||
|
|
||||||
cache.StartEviction(100 * time.Millisecond)
|
cache.StartEviction(100 * time.Millisecond)
|
||||||
defer func() { _ = cache.StopEviction(t.Context()) }()
|
defer func() { _ = cache.StopEviction(t.Context()) }()
|
||||||
|
|
||||||
|
deadline := time.Now().Add(5 * time.Second)
|
||||||
|
|
||||||
|
for cache.db.Stats().WaitCount == 0 {
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
t.Fatal("the startup pass never waited for the database")
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
keys := []VariantKey{
|
keys := []VariantKey{
|
||||||
testVariantKeyOne, testVariantKeyTwo, testVariantKeyThree,
|
testVariantKeyOne, testVariantKeyTwo, testVariantKeyThree,
|
||||||
}
|
}
|
||||||
@@ -703,25 +724,43 @@ func TestEvictionRunsOnPeriodicSchedule(t *testing.T) {
|
|||||||
for i, key := range keys {
|
for i, key := range keys {
|
||||||
content := bytes.Repeat([]byte{fills[i]}, 1000)
|
content := bytes.Repeat([]byte{fills[i]}, 1000)
|
||||||
|
|
||||||
_, err := cache.variants.Store(key, bytes.NewReader(content), "image/webp")
|
_, err = cache.variants.Store(key, bytes.NewReader(content), "image/webp")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to store variant file: %v", err)
|
t.Fatalf("failed to store variant file: %v", err)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
_, err = cache.db.ExecContext(t.Context(),
|
_ = conn.Close()
|
||||||
`INSERT INTO variant_content (cache_key, size_bytes, content_type)
|
|
||||||
VALUES (?, ?, ?)`,
|
// Only one of the 1000-byte files fits under the limit: wait until
|
||||||
string(key), len(content), "image/webp",
|
// the evictor has removed the other two.
|
||||||
)
|
stored := len(keys)
|
||||||
if err != nil {
|
deadline = time.Now().Add(5 * time.Second)
|
||||||
t.Fatalf("failed to insert variant accounting row: %v", err)
|
|
||||||
|
for stored > 1 && time.Now().Before(deadline) {
|
||||||
|
time.Sleep(25 * time.Millisecond)
|
||||||
|
|
||||||
|
stored = 0
|
||||||
|
|
||||||
|
for _, key := range keys {
|
||||||
|
if cache.variants.Exists(key) {
|
||||||
|
stored++
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
usage := waitForUsageAtOrBelow(t, cache, limit, 5*time.Second)
|
if stored > 1 {
|
||||||
|
t.Fatalf("periodic schedule did not trigger eviction: %d of %d "+
|
||||||
|
"variant files still on disk, want at most 1", stored, len(keys))
|
||||||
|
}
|
||||||
|
|
||||||
|
usage, err := cache.UsageBytes(t.Context())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UsageBytes failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
if usage > limit {
|
if usage > limit {
|
||||||
t.Errorf("periodic schedule did not trigger eviction: usage = %d, want <= %d",
|
t.Errorf("usage after eviction = %d, want <= %d", usage, limit)
|
||||||
usage, limit)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
assertNoDanglingReferences(t, cache)
|
assertNoDanglingReferences(t, cache)
|
||||||
|
|||||||
Reference in New Issue
Block a user