1 Commits
Author SHA1 Message Date
sneak d60f537304 Report file writes run one at a time (closes #150)
check / check (push) Canceled after 0s
Two writes that failed at the same time put their reports back in the
wrong order when the older one failed first. A mutex is now held
across each write, from taking the waiting reports out of the buffer
to putting them back if the write fails, so the next write takes them
in order. The write Append starts at the 10 MiB threshold waits for
one under way, and Append starts no other while it waits. Files
written since the start are now deleted to make room in the order they
were written. A test that needed two writes at once is removed; new
tests cover the one waiting write and the order files are deleted in.

Model: opus-5-5
2026-10-19 03:16:47 +00:00
4 changed files with 22 additions and 66 deletions
+6 -7
View File
@@ -231,7 +231,7 @@ image, `make run` builds it and runs it on port 8080.
| `PORT` | `8080` | HTTP listen port |
| `DATA_DIR` | `./data/reports` | Directory for compressed reports |
| `DATA_DIR_MAX_BYTES` | `1073741824` (1 GiB) | Most bytes of report files kept in `DATA_DIR`, oldest deleted first; see [Report limits](#report-limits) |
| `DEBUG` | empty | Debug logging; any non-empty value, `false` and `0` included, turns it on |
| `DEBUG` | `false` | Enable debug logging |
| `TRUSTED_PROXIES` | RFC 1918 ranges | Comma-separated CIDRs whose `X-Forwarded-For` / `X-Real-IP` headers are trusted for client IP resolution |
| `REPORTS_PER_MINUTE` | `60` | Reports each client address may send a minute; see [Report limits](#report-limits) |
| `CORS_ALLOWED_ORIGINS` | empty | Comma-separated origins whose pages may call the API; see [CORS](#cors) |
@@ -254,10 +254,10 @@ an entry that is not an address at all, such as `unknown`, the direct peer is
used. `X-Real-IP` is read only when `X-Forwarded-For` is missing or empty. The
container image does not use this default; see [Deployment](#deployment).
A variable set to a value the server cannot use, such as `PORT=abc` or a
`BIND_ADDRESS` that is not an IP address, stops it from starting, with an error
naming the variable. An empty variable counts as unset, except
`TRUSTED_PROXIES`. No value of `DEBUG` stops the start.
A variable set to a value the server cannot use, such as `PORT=abc`,
`DEBUG=maybe` or a `BIND_ADDRESS` that is not an IP address, stops it from
starting, with an error naming the variable. An empty variable counts as unset,
except `TRUSTED_PROXIES`.
#### Report storage
@@ -522,8 +522,7 @@ What the [upaas](https://git.eeqj.de/sneak/upaas) app for netwatch needs:
report files may take; the oldest are deleted to stay under it
- `CORS_ALLOWED_ORIGINS`, default empty: other origins whose pages may call
the API
- `DEBUG`, default empty: debug logging; any non-empty value, `false` and
`0` included, turns it on
- `DEBUG`, default `false`: debug logging
- `DATA_DIR`, default `/data/reports`: the directory the reports are kept
in: `/data` or a path below it, with no `.` or `..` part and no extra `/`.
The container also stops if the path goes through a symbolic link that
-6
View File
@@ -34,12 +34,6 @@ Run `make frontend-viewport-test` in CI as its own step; it is not part of
order in the next file. Others check that `Append` starts no second write at
10 MiB while one waits, that it starts one again the next time 10 MiB are
waiting, and that the first file written since the start is the first deleted
- 2026-10-19: `DEBUG` unset or empty leaves debug logging off, and any other
value, `false` and `0` included, turns it on, as the code style guide says
([#159](https://git.eeqj.de/sneak/netwatch/issues/159)). No value of it stops
the start any more; before, only `1`, `t`, `T`, `TRUE`, `true`, `True`, `0`,
`f`, `F`, `FALSE`, `false` and `False` were accepted. `README.md` no longer
gives `false` as its default
- 2026-10-19: check builds keep Go's modules and compiled packages between runs
([#118](https://git.eeqj.de/sneak/netwatch/issues/118)), on sneak's rulings in
[prompts#113](https://git.eeqj.de/sneak/prompts/issues/113) and
+11 -8
View File
@@ -41,6 +41,7 @@ var (
"must be an origin, scheme://host with an optional port",
)
errNotPort = errors.New("must be a port number, 1 to 65535")
errNotBool = errors.New("must be true or false")
errNotIP = errors.New("must be an IP address, or empty")
errMetricsCredentials = errors.New(
@@ -100,7 +101,7 @@ func New(
viper.SetDefault("CORS_ALLOWED_ORIGINS", "")
viper.SetDefault("DATA_DIR", "./data/reports")
viper.SetDefault("DATA_DIR_MAX_BYTES", defaultDataDirMaxBytes)
viper.SetDefault("DEBUG", "")
viper.SetDefault("DEBUG", "false")
// An empty BIND_ADDRESS listens on every interface.
viper.SetDefault("BIND_ADDRESS", "")
viper.SetDefault("PORT", "8080")
@@ -119,17 +120,19 @@ func New(
}
}
// Read with strconv: viper's GetInt would read a value it cannot
// parse as 0 instead of failing.
// Read with strconv: viper's GetInt and GetBool would read a value
// they cannot parse as 0 or false instead of failing.
port, err := strconv.Atoi(viper.GetString("PORT"))
if err != nil || port < 1 || port > math.MaxUint16 {
return nil, fmt.Errorf("PORT %q: %w",
viper.GetString("PORT"), errNotPort)
}
// Any non-empty value turns debug logging on, "false" and "0"
// included, as the code style guide says.
debug := viper.GetString("DEBUG") != ""
debug, err := strconv.ParseBool(viper.GetString("DEBUG"))
if err != nil {
return nil, fmt.Errorf("DEBUG %q: %w",
viper.GetString("DEBUG"), errNotBool)
}
// An empty TRUSTED_PROXIES trusts no proxy, but viper reads an
// empty environment variable as unset and would give the default,
@@ -169,8 +172,8 @@ func New(
}
// check fails with an error naming the first setting here whose value
// the server cannot use. New checks PORT as it reads it, and the
// middleware checks TRUSTED_PROXIES as it parses it.
// the server cannot use. New checks PORT and DEBUG as it reads them,
// and the middleware checks TRUSTED_PROXIES as it parses it.
func (s *Config) check() error {
// viper reads a value that is not a number as 0, so this also
// catches a mistyped setting.
+5 -45
View File
@@ -127,52 +127,12 @@ func TestPortMustBeAPortNumber(t *testing.T) {
}
}
// TestDebugIsOnForAnyNonEmptyValue: unset or empty, debug logging is
// off; any other value turns it on, "false" and "0" included, and none
// stops the start.
func TestDebugIsOnForAnyNonEmptyValue(t *testing.T) {
for _, tc := range []struct {
name string
value string
unset bool
want bool
}{
{name: "unset", unset: true, want: false},
{name: "empty", value: "", want: false},
{name: "false", value: "false", want: true},
{name: "0", value: "0", want: true},
{name: "yes", value: "yes", want: true},
} {
t.Run(tc.name, func(t *testing.T) {
// t.Setenv first, so the variable is put back as it was
// after the test.
t.Setenv("DEBUG", tc.value)
// TestDebugMustBeTrueOrFalse: viper reads any other value, such as
// "yes", as false.
func TestDebugMustBeTrueOrFalse(t *testing.T) {
t.Setenv("DEBUG", "yes")
if tc.unset {
err := os.Unsetenv("DEBUG")
if err != nil {
t.Fatal(err)
}
}
var cfg *config.Config
app := fx.New(
fx.NopLogger,
fx.Provide(globals.New, logger.New, config.New),
fx.Populate(&cfg),
)
err := app.Err()
if err != nil {
t.Fatalf("config error = %v", err)
}
if cfg.Debug != tc.want {
t.Fatalf("Debug = %t, want %t", cfg.Debug, tc.want)
}
})
}
requireConfigError(t, "DEBUG")
}
// TestBindAddressMustBeAnIPAddress: a host name would be looked up