Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
83bea41607 | ||
|
|
8f2779a0cc |
@@ -1,4 +0,0 @@
|
|||||||
# 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, only one that does not exist is passed over, without a message. One
|
its own, one it finds but cannot read or parse aborts startup; one it cannot
|
||||||
that pixa cannot read or parse aborts startup, naming the file. So does one in a
|
find, for any reason, is passed over without a message, even when the file is
|
||||||
directory pixa may not enter, whether or not it is there, since pixa cannot
|
there in a directory pixa may not enter. With no file, pixa uses the environment
|
||||||
tell. With no file, pixa uses the environment and the defaults.
|
and the defaults.
|
||||||
|
|
||||||
| Variable | Config key | Meaning |
|
| Variable | Config key | Meaning |
|
||||||
| ------------------------------------ | ------------------------------- | ---------------------------------------------------------------------------- |
|
| ------------------------------------ | ------------------------------- | ---------------------------------------------------------------------------- |
|
||||||
|
|||||||
@@ -3,8 +3,6 @@
|
|||||||
* 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`
|
||||||
@@ -31,28 +29,6 @@ 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
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ package config
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"math"
|
"math"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
@@ -15,7 +14,6 @@ import (
|
|||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"syscall"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/smartconfig"
|
"git.eeqj.de/sneak/smartconfig"
|
||||||
@@ -780,19 +778,10 @@ 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 errors.Is(statErr, fs.ErrNotExist) || errors.Is(statErr, syscall.ENOTDIR) {
|
if statErr == nil {
|
||||||
continue
|
// A config file that exists but does not parse is a fatal
|
||||||
}
|
// startup error, never something to skip over.
|
||||||
|
|
||||||
if statErr != nil {
|
|
||||||
return nil, fmt.Errorf("failed to read config file %s: %w", path, statErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
sc, err := smartconfig.NewFromConfigPath(path)
|
sc, err := smartconfig.NewFromConfigPath(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to parse config file %s: %w", path, err)
|
return nil, fmt.Errorf("failed to parse config file %s: %w", path, err)
|
||||||
@@ -802,6 +791,7 @@ func loadConfigFile(log *slog.Logger, appName string) (*smartconfig.Config, erro
|
|||||||
|
|
||||||
return sc, nil
|
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,116 +564,6 @@ 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,11 +681,6 @@ 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()
|
||||||
|
|
||||||
@@ -693,29 +688,13 @@ func TestEvictionRunsOnPeriodicSchedule(t *testing.T) {
|
|||||||
|
|
||||||
cache, _ := newEvictionTestCache(t, limit)
|
cache, _ := newEvictionTestCache(t, limit)
|
||||||
|
|
||||||
// Hold the test database's only connection, so the startup pass
|
// Start the evictor while the cache is empty, then create tracked
|
||||||
// waits for it after walking the still empty variant directory: the
|
// over-limit state WITHOUT going through the store methods, so no
|
||||||
// files written while it waits are first seen by a periodic pass.
|
// write-pressure notification fires and only the periodic ticker
|
||||||
conn, err := cache.db.Conn(t.Context())
|
// can trigger eviction.
|
||||||
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,
|
||||||
}
|
}
|
||||||
@@ -724,43 +703,25 @@ 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)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
_ = conn.Close()
|
_, err = cache.db.ExecContext(t.Context(),
|
||||||
|
`INSERT INTO variant_content (cache_key, size_bytes, content_type)
|
||||||
// Only one of the 1000-byte files fits under the limit: wait until
|
VALUES (?, ?, ?)`,
|
||||||
// the evictor has removed the other two.
|
string(key), len(content), "image/webp",
|
||||||
stored := len(keys)
|
)
|
||||||
deadline = time.Now().Add(5 * time.Second)
|
|
||||||
|
|
||||||
for stored > 1 && time.Now().Before(deadline) {
|
|
||||||
time.Sleep(25 * time.Millisecond)
|
|
||||||
|
|
||||||
stored = 0
|
|
||||||
|
|
||||||
for _, key := range keys {
|
|
||||||
if cache.variants.Exists(key) {
|
|
||||||
stored++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
if err != nil {
|
||||||
t.Fatalf("UsageBytes failed: %v", err)
|
t.Fatalf("failed to insert variant accounting row: %v", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
usage := waitForUsageAtOrBelow(t, cache, limit, 5*time.Second)
|
||||||
if usage > limit {
|
if usage > limit {
|
||||||
t.Errorf("usage after eviction = %d, want <= %d", usage, limit)
|
t.Errorf("periodic schedule did not trigger eviction: usage = %d, want <= %d",
|
||||||
|
usage, limit)
|
||||||
}
|
}
|
||||||
|
|
||||||
assertNoDanglingReferences(t, cache)
|
assertNoDanglingReferences(t, cache)
|
||||||
|
|||||||
Reference in New Issue
Block a user