3 Commits
Author SHA1 Message Date
clawbot d293ab74d1 Space reconciliation passes by ten times the last pass's time (closes #250)
check / check (push) Canceled after 0s
The reconciliation pass reads a row and stats a file for every cached
file, and it ran on every eviction tick, so on a large cache a pass
longer than the tick was followed by the next one at once. It still runs
at startup and is considered on each tick, but it now starts no sooner
than ten times as long after the last pass ended as that pass took.
Eviction and the removal of expired negative cache rows still run on
every tick. The comments that bound the drift, and README.md, give the
new bound.

Model: opus-5-5
2026-10-19 02:28:23 +00:00
clawbot 7933619692 Test that reconciliation passes are spaced by ten times their time
The new test makes every read of the reconciliation pass sleep 10 ms
through reconciliationReadTestHook, runs the eviction loop on a 10 ms
interval, and checks that each pass starts at least ten times as long
after the previous one ended as that one took. It fails while the pass
runs on every eviction tick, since a slow pass is then followed by the
next one at once.

Model: opus-5-5
2026-10-19 02:26:58 +00:00
clawbot 0ab15324f7 Read a blocked_networks entry in the IPv4-mapped form as its IPv4 range (closes #301)
check / check (push) Canceled after 0s
The fetcher matches an IPv4-mapped address (::ffff:a.b.c.d) as the
IPv4 address it holds, so a blocked_networks entry written in that form
was accepted and matched nothing. Such an entry is now kept as the IPv4
range it covers: ::ffff:10.0.0.0/104 becomes 10.0.0.0/8, and
::ffff:0:0/96 becomes 0.0.0.0/0. One shorter than /96 covers more than
IPv4 addresses and aborts startup naming the setting. README.md says
both. trusted_proxies shares the list parser, has the same fault and is
unchanged here (issue 303).

Model: opus-5-5
2026-10-19 03:00:43 +02:00
5 changed files with 115 additions and 20 deletions
+7 -3
View File
@@ -200,8 +200,10 @@ When pixa starts, and every five minutes after, it removes each cached source or
transformed image whose file is empty, or not the size the database records for
it, with a warning naming the file; the next request for it fetches the source
or converts the image again. Looking for those files reads the database once for
every cached file, so after a look that took longer than 30 seconds, pixa waits
ten times as long as it took before looking again.
every cached file, so pixa skips each five-minute look that would start less
than ten times as long after the last look ended as that look took. After a look
that took one minute, the next one starts 10 to 15 minutes after that look
ended.
pixa's target is 1-5k r/s, which has not been measured at that rate (see Load
Test). Toward it, pixa keeps in memory the content types of the 10,000
@@ -699,7 +701,9 @@ Key settings in more detail:
benchmark, NAT64, and the like); an invalid CIDR aborts startup. An IPv6
address in the IPv4-mapped form (`::ffff:a.b.c.d`) is matched only as the IPv4
address it holds, so an IPv6 range such as `::/0` refuses neither it nor a
plain IPv4 address
plain IPv4 address. An entry written in that form is read as the IPv4 range it
covers, so `::ffff:10.0.0.0/104` refuses what `10.0.0.0/8` refuses; such an
entry shorter than `/96` covers more than IPv4 addresses and aborts startup
- `trusted_proxies` — list of CIDR ranges of the reverse proxies in front of
pixa. `X-Forwarded-For` is believed only when the direct peer falls inside one
of these ranges; the logged and login-recorded client address is then the
+6 -2
View File
@@ -30,8 +30,12 @@ P2: security: per-origin rate limiting
# Completed Steps
- 2026-10-18 the next reconciliation pass waits ten times as long as the last
one took, so passes on a large cache no longer run back to back (closes #250).
- 2026-10-18 the next reconciliation pass waits at least ten times as long as
the last one took, so passes on a large cache no longer run back to back
(closes #250).
- 2026-10-18 a `blocked_networks` entry in the IPv4-mapped form refused nothing;
`::ffff:10.0.0.0/104` is now read as `10.0.0.0/8`, and such an entry shorter
than `/96` aborts startup (closes #301).
- 2026-10-16 P1 security bug: the check that keeps pixa from fetching internal
addresses refuses the local-use NAT64 range (`64:ff9b:1::/48`, RFC 8215) as it
refuses `64:ff9b::/96`, and refuses an IPv6 address in the IPv4-compatible
@@ -88,3 +88,56 @@ func TestBlockedNetworksInvalidAbortsStartup(t *testing.T) {
},
})
}
// TestBlockedNetworksMappedEntryIsIPv4Range checks that an entry written in
// the IPv4-mapped form (::ffff:a.b.c.d) is kept as the IPv4 range it covers,
// since the fetcher matches an IPv4-mapped address as the IPv4 address it
// holds. /96, the shortest such entry, covers every IPv4 address.
func TestBlockedNetworksMappedEntryIsIPv4Range(t *testing.T) {
t.Parallel()
yamlContent := signingKeyLine + `blocked_networks:
- "::ffff:10.0.0.0/104"
- "::ffff:0:0/96"
`
c, err := configFromYAML(t, yamlContent)
if err != nil {
t.Fatalf("IPv4-mapped blocked_networks entries should load, got error: %v", err)
}
want := []string{"10.0.0.0/8", "0.0.0.0/0"}
if len(c.BlockedNetworks) != len(want) {
t.Fatalf("BlockedNetworks = %v, want %d entries", c.BlockedNetworks, len(want))
}
for i, w := range want {
if got := c.BlockedNetworks[i].String(); got != w {
t.Errorf("BlockedNetworks[%d] = %q, want %q", i, got, w)
}
}
}
// TestBlockedNetworksMappedEntryShorterThan96AbortsStartup checks that an
// entry in the IPv4-mapped form shorter than /96, which covers more than the
// IPv4 addresses, aborts startup with an error naming the key and the value.
func TestBlockedNetworksMappedEntryShorterThan96AbortsStartup(t *testing.T) {
t.Parallel()
runAbortCases(t, []abortCase{
{
name: "mapped-shorter-than-96",
yaml: signingKeyLine + `blocked_networks:
- "::ffff:0.0.0.0/80"
`,
wantErrSubstrings: []string{keyBlockedNetworks, "::ffff:0.0.0.0/80"},
},
{
name: "mapped-95",
yaml: signingKeyLine + `blocked_networks:
- "::ffff:0:0/95"
`,
wantErrSubstrings: []string{keyBlockedNetworks},
},
})
}
+35 -1
View File
@@ -91,6 +91,7 @@ var (
errNotABoolean = errors.New("not a boolean")
errNotAStringList = errors.New("not a list of strings")
errNotAValidCIDR = errors.New("not a valid CIDR network")
errMappedTooShort = errors.New("in the IPv4-mapped form but shorter than /96")
errNotAMetricsMap = errors.New("not a map of metrics settings")
errEmptyListEntry = errors.New("list contains an empty entry")
errEmptyEntry = errors.New("contains an empty entry")
@@ -271,7 +272,7 @@ func newFromSmartConfig(sc *smartconfig.Config) (*Config, error) {
return nil, err
}
blockedNetworks, err := parseCIDRList(sc, keyBlockedNetworks)
blockedNetworks, err := parseBlockedNetworks(sc)
if err != nil {
return nil, err
}
@@ -1278,6 +1279,39 @@ func parseCIDRList(sc *smartconfig.Config, key string) ([]netip.Prefix, error) {
return prefixes, nil
}
// parseBlockedNetworks parses blocked_networks with parseCIDRList, and keeps
// an entry written in the IPv4-mapped form (::ffff:a.b.c.d) as the IPv4
// range it covers: ::ffff:10.0.0.0/104 is kept as 10.0.0.0/8. The fetcher
// matches an IPv4-mapped address as the IPv4 address it holds, so the entry
// as written would match nothing. Such an entry shorter than /96 covers more
// than the IPv4 addresses and aborts startup.
func parseBlockedNetworks(sc *smartconfig.Config) ([]netip.Prefix, error) {
prefixes, err := parseCIDRList(sc, keyBlockedNetworks)
if err != nil {
return nil, err
}
// An IPv4-mapped address is the 96 bits of ::ffff:0:0/96 followed by
// the 32 bits of the IPv4 address it holds.
const mappedPrefixBits = 96
for i, prefix := range prefixes {
if !prefix.Addr().Is4In6() {
continue
}
if prefix.Bits() < mappedPrefixBits {
return nil, fmt.Errorf("%s: value %q is %w",
settingName(keyBlockedNetworks), prefix, errMappedTooShort)
}
prefixes[i] = netip.PrefixFrom(prefix.Addr().Unmap(),
prefix.Bits()-mappedPrefixBits)
}
return prefixes, nil
}
// parseHostList parses the value of the named config key into host patterns,
// or returns nil if the key is omitted. It accepts a YAML list of strings or a
// comma-separated string. An explicitly null value, a wrong type, an empty
+14 -14
View File
@@ -19,8 +19,8 @@ import (
const DefaultEvictionInterval = 5 * time.Minute
// reconciliationWaitFactor is how many times as long as a
// reconciliation pass took the eviction loop waits, after that pass
// ends, before it starts the next one. A pass reads a row and stats a
// reconciliation pass took the eviction loop waits at least, after that
// pass ends, before it starts the next one. A pass reads a row and stats a
// file for every cached file, so its time grows with the cache; waiting
// ten times as long keeps reconciliation to at most a tenth of the time,
// however large the cache is.
@@ -449,8 +449,8 @@ func (c *Cache) notifyWritePressure() {
// removes the expired negative cache rows at startup and again on every
// periodic tick thereafter, reconciles the database accounting with the
// cache directories at startup and again on the first periodic tick
// that comes ten times as long after a reconciliation pass ended as
// that pass took, and evicts to the configured limit on the given
// that comes at least ten times as long after a reconciliation pass
// ended as that pass took, and evicts to the configured limit on the given
// periodic interval and on write-pressure notifications. On a
// disabled cache it only removes the expired negative cache rows, as
// the negative cache is used either way. It is a no-op when already
@@ -507,13 +507,13 @@ func (c *Cache) evictionLoop(ctx context.Context, interval time.Duration) {
// Reconciliation walks the cache directories and reads a
// row for every file, so it runs only on the periodic
// ticker rather than on every write-pressure wakeup, and
// not before nextReconciliation, which is ten times as long
// after the last pass ended as that pass took. Passes then
// never run back to back, however long one takes. A file
// whose best-effort accounting insert failed in a store
// stays unaccounted for until the next pass, for about the
// longer of one eviction interval and ten times the last
// pass's time.
// only on the first tick at or after nextReconciliation,
// which is ten times as long after the last pass ended as
// that pass took. Passes then never run back to back,
// however long one takes. A file whose best-effort
// accounting insert failed in a store stays unaccounted for
// until the next pass, up to about one eviction interval
// plus ten times the last pass's time.
if !time.Now().Before(nextReconciliation) {
nextReconciliation = c.runReconciliationPass(ctx)
}
@@ -578,7 +578,7 @@ func (c *Cache) runCleanExpired(ctx context.Context) {
// reconcileAccounting synchronizes the database size accounting with
// the actual contents of the cache directories. It runs at startup and
// again on later periodic eviction ticks, each no sooner than ten times
// then on the first periodic eviction tick that comes at least ten times
// as long after the last pass ended as that pass took, off the request
// hot path: it adopts variant files that predate the accounting table
// (or whose accounting insert failed, e.g. StoreVariant's best-effort
@@ -589,8 +589,8 @@ func (c *Cache) runCleanExpired(ctx context.Context) {
// files left behind by crashed writes, and last checks the total cache
// usage against the tables. It reads the tables a page at a time.
// Running it periodically, not just once, bounds how long such drift can
// accumulate unaccounted for on a long-running process to about the
// longer of one eviction interval and ten times the last pass's time.
// accumulate unaccounted for on a long-running process to about one
// eviction interval plus ten times the last pass's time.
// Once ctx is cancelled, it stops at the next file or row and returns
// ctx's error.
func (c *Cache) reconcileAccounting(ctx context.Context) error {