6 Commits
Author SHA1 Message Date
clawbot c29b68e232 Pass over a config path that runs through a file
check / check (push) Failing after 2s
Of the places pixa looks for its config file on its own, one whose path
runs through a file, such as under a HOME of /dev/null, aborted startup
with "not a directory", though no file can be there. It is now passed
over like a file that does not exist, as README.md says.

Model: opus-5-5
2026-10-04 15:59:19 +00:00
clawbot 52db3cf0c5 Test a config file that links to itself and a HOME that is a file
The test for a directory pixa may not enter is skipped as root, where
the gate runs the tests. A config.yml in the working directory that is
a symbolic link to itself fails os.Stat as root too, and must abort
startup. A config path that runs through a file, such as one under a
HOME of /dev/null, cannot hold a file and must be passed over like one
that does not exist; today it aborts startup.

Model: opus-5-5
2026-10-04 15:59:19 +00:00
clawbot aace8e78b3 Abort startup on a config file pixa cannot read (closes #176)
Of the places pixa looks for its config file on its own, it passed over
any place where os.Stat failed, so a file in a directory pixa may not
enter was skipped without a word and pixa started on a later file or on
the environment and defaults. Now only a file that does not exist is
passed over; any other error aborts startup naming the file, as a file
that does not parse already did. README.md says so where it gives the
search order.

Model: opus-5-5
2026-10-04 15:59:19 +00:00
clawbot 9cddc4447e Test that a config file pixa may not read aborts startup
Of the places pixa looks for its config file on its own, a file in a
directory pixa may not enter is passed over today and pixa starts
without it. This test puts the config file in such a directory and
expects startup to abort with an error naming the file. It is skipped
when run as root, which may enter any directory.

Model: opus-5-5
2026-10-04 15:59:19 +00:00
clawbot f8c437b83f Merge TODO.md with git's union merge (closes #190)
check / check (push) Failing after 3s
Every PR adds an entry at the top of Completed Steps in TODO.md, so each
merge to next left the other open PRs conflicting there. A root
.gitattributes, copied from sneak/prompts, marks TODO.md merge=union: two
branches that each add an entry at the same place merge without a conflict
and keep both. Git then never reports a conflict in TODO.md, so the
Workflow now says to read the merged entries after every merge or rebase.

Model: opus-5-5
2026-10-04 17:58:41 +02:00
clawbot 04093f53ad Stop TestEvictionRunsOnPeriodicSchedule racing the evictor (closes #183)
check / check (push) Failing after 1s
The test wrote each variant file and then inserted its accounting row by
hand while the evictor was running. A reconciliation pass between the two
steps adopted the file first, and the hand insert failed on the unique key.

The test now writes the files only, while it holds the test database's
only connection, so the evictor's startup pass waits after walking the
still empty variant directory. A periodic reconciliation pass then adopts
the files and the eviction pass after it evicts them; no write-pressure
notification fires. The test waits until two of the three files are gone,
then checks that usage is within the limit and that no row points at a
missing file.

Model: opus-5-5
2026-10-04 16:58:34 +02:00
3 changed files with 77 additions and 15 deletions
+4
View File
@@ -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
+19
View File
@@ -3,6 +3,8 @@
* branch per issue from `next`
* do the work in Next Step
* 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
* commit (`TODO.md` changes in the same commit as the work)
* open a PR based on `next`
@@ -29,6 +31,23 @@ P2: security: referer blacklist
# 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
+54 -15
View File
@@ -681,6 +681,11 @@ func TestEvictionRunsUnderWritePressure(t *testing.T) {
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) {
t.Parallel()
@@ -688,13 +693,29 @@ func TestEvictionRunsOnPeriodicSchedule(t *testing.T) {
cache, _ := newEvictionTestCache(t, limit)
// Start the evictor while the cache is empty, then create tracked
// over-limit state WITHOUT going through the store methods, so no
// write-pressure notification fires and only the periodic ticker
// can trigger eviction.
// Hold the test database's only connection, so the startup pass
// waits for it after walking the still empty variant directory: the
// files written while it waits are first seen by a periodic pass.
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)
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{
testVariantKeyOne, testVariantKeyTwo, testVariantKeyThree,
}
@@ -703,25 +724,43 @@ func TestEvictionRunsOnPeriodicSchedule(t *testing.T) {
for i, key := range keys {
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 {
t.Fatalf("failed to store variant file: %v", err)
}
}
_, err = cache.db.ExecContext(t.Context(),
`INSERT INTO variant_content (cache_key, size_bytes, content_type)
VALUES (?, ?, ?)`,
string(key), len(content), "image/webp",
)
if err != nil {
t.Fatalf("failed to insert variant accounting row: %v", err)
_ = conn.Close()
// Only one of the 1000-byte files fits under the limit: wait until
// the evictor has removed the other two.
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++
}
}
}
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 {
t.Errorf("periodic schedule did not trigger eviction: usage = %d, want <= %d",
usage, limit)
t.Errorf("usage after eviction = %d, want <= %d", usage, limit)
}
assertNoDanglingReferences(t, cache)