Compare commits

..

3 Commits

Author SHA1 Message Date
sulthan cddd16bcdc Merge pull request 'fix: series removal sent its status line twice' (#176) from fix/series-remove-double-writeheader into main 2026-08-23 14:02:45 +07:00
sulthan c1616b3162 fix: series removal sent its status line twice
adminSeriesRemove answered the list surface with two h.render calls -- the
row fragment and the out-of-band heading -- and h.render writes a status
line each time, so every removal logged "superfluous
response.WriteHeader call". The heading is an append to a response
already committed, so it now executes straight onto w, the way
writeChromeOOB already does it.

The regression test runs the router under a real server with a captured
ErrorLog: a ResponseRecorder never sees this warning, which is why the
existing removal test did not catch it.
2026-08-23 14:02:27 +07:00
sulthan e1ba7fdabb fix: pgtest left an anonymous volume behind on every run (#175)
## Problem

`go test ./...` leaked one Docker volume per test package. `pgtest.Main` tore its throwaway container down with `docker rm -f` — no `-v`. The `postgres:17-alpine` image declares a `VOLUME` for `/var/lib/postgresql/data`, and an explicit `rm` without `-v` orphans that anonymous volume even though the container was created with `--rm`.

Found in passing: a 13-hour-old orphaned pgtest container (AutoRemove, `fsync=off`, one mount) still running from a killed test binary — the same leak's other half. Removed on the dev machine.

## Change

- `backend/internal/pgtest/pgtest.go`: `-v` on all three `docker rm -f` teardown sites (`Main`'s defer, and both error paths in `start`).
- `AGENTS.md`: cleanup expectation under Commands — `docker compose down -v` for a stack, `docker rm -f -v` for a hand-run container, then check `docker volume ls` and `docker system df`. Explicitly out of bounds: removing the user's `postgres-data` volume, or a blanket `docker system prune` of their images and build cache.

## Verification

`docker volume ls` snapshot diffed across a real `./internal/store` run: no new volumes, `docker system df` reports 0 local volumes.

Reviewed-on: #175
Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com>
Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
2026-08-23 13:14:49 +07:00
4 changed files with 67 additions and 5 deletions
+9
View File
@@ -50,6 +50,15 @@ Backend (`cd backend`):
Local stack: `docker compose up` (bookmark-api + postgres only; `postgres-data` named volume, `restart: unless-stopped`). No browser — without `BROWSER_WS_URL` the poller logs and skips kagane and comix. To run one: `cd chrome && BROWSER_BIND_ADDR=172.17.0.1 docker compose up -d --build`, then `BROWSER_WS_URL=ws://172.17.0.1:9222` in the root `.env` (bridge gateway, so the API container can name it by IP). Local stack: `docker compose up` (bookmark-api + postgres only; `postgres-data` named volume, `restart: unless-stopped`). No browser — without `BROWSER_WS_URL` the poller logs and skips kagane and comix. To run one: `cd chrome && BROWSER_BIND_ADDR=172.17.0.1 docker compose up -d --build`, then `BROWSER_WS_URL=ws://172.17.0.1:9222` in the root `.env` (bridge gateway, so the API container can name it by IP).
**Clean up Docker after testing.** Storage on the dev machine is scarce, so
anything you started for a test you also tear down before calling the work
done: `docker compose down -v` for a stack you brought up, `docker rm -f -v`
for a container you ran by hand (`-v`, or the image's anonymous data volume
survives). Then check `docker volume ls` and `docker system df` for leftovers
and reclaim them — a dangling volume nobody notices is the leak that fills the
disk. Never remove the `postgres-data` volume of a stack the user is actually
running, and never blanket-`docker system prune` their images or build cache.
Live CDP proof (needs that browser and network, skipped otherwise): Live CDP proof (needs that browser and network, skipped otherwise):
`SMOKE_BROWSER_WS_URL=ws://<ip>:<port> go test -run 'TestSmokeKagane|TestSmokeComix' ./internal/latest` `SMOKE_BROWSER_WS_URL=ws://<ip>:<port> go test -run 'TestSmokeKagane|TestSmokeComix' ./internal/latest`
— fetches a real kagane and comix cover and chapter list. A red run means the challenge is — fetches a real kagane and comix cover and chapter list. A red run means the challenge is
+5 -3
View File
@@ -40,7 +40,9 @@ func Main(m *testing.M) int {
fmt.Println("pgtest:", err) fmt.Println("pgtest:", err)
return 1 return 1
} }
defer exec.Command("docker", "rm", "-f", id).Run() // -v: the postgres image declares a VOLUME, so an explicit rm without it
// leaves the anonymous data volume behind on every test run.
defer exec.Command("docker", "rm", "-f", "-v", id).Run()
adminURL = url adminURL = url
return m.Run() return m.Run()
@@ -85,7 +87,7 @@ func start() (id, url string, err error) {
port, err := exec.Command("docker", "port", id, "5432/tcp").Output() port, err := exec.Command("docker", "port", id, "5432/tcp").Output()
if err != nil { if err != nil {
exec.Command("docker", "rm", "-f", id).Run() exec.Command("docker", "rm", "-f", "-v", id).Run()
return "", "", fmt.Errorf("docker port: %w", err) return "", "", fmt.Errorf("docker port: %w", err)
} }
// "0.0.0.0:32768" (and possibly a second, IPv6 line); the port is all we want. // "0.0.0.0:32768" (and possibly a second, IPv6 line); the port is all we want.
@@ -94,7 +96,7 @@ func start() (id, url string, err error) {
first[strings.LastIndex(first, ":")+1:]) first[strings.LastIndex(first, ":")+1:])
if err := waitReady(url); err != nil { if err := waitReady(url); err != nil {
exec.Command("docker", "rm", "-f", id).Run() exec.Command("docker", "rm", "-f", "-v", id).Run()
return "", "", err return "", "", err
} }
return id, url, nil return id, url, nil
+5 -2
View File
@@ -505,10 +505,13 @@ func (h *Handler) adminSeriesRemove(w http.ResponseWriter, r *http.Request) {
band = 1 band = 1
} }
h.render(w, http.StatusOK, "series-row", seriesRow(a, band, time.Now())) h.render(w, http.StatusOK, "series-row", seriesRow(a, band, time.Now()))
// The heading is an out-of-band append to a response whose status line has
// already gone out with the row, so it is executed straight onto w —
// h.render would send a second WriteHeader.
if head, err := h.seriesListHeadView(r); err != nil { if head, err := h.seriesListHeadView(r); err != nil {
log.Printf("series remove %s: %v", site+":"+seriesID, err) log.Printf("series remove %s: %v", site+":"+seriesID, err)
} else { } else if err := h.tmpl.ExecuteTemplate(w, "series-list-head", head); err != nil {
h.render(w, http.StatusOK, "series-list-head", head) log.Printf("series remove %s: render series-list-head oob: %v", site+":"+seriesID, err)
} }
} }
+48
View File
@@ -1,11 +1,13 @@
package main package main
import ( import (
"bytes"
"crypto/sha256" "crypto/sha256"
"database/sql" "database/sql"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"log"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url" "net/url"
@@ -4285,6 +4287,52 @@ func TestRemoveFromListAnswersRowAndFreshHeading(t *testing.T) {
} }
} }
// The removal answers two fragments on one response. The second is an
// out-of-band append, so it must not send a second status line: net/http
// answers a double WriteHeader with "superfluous response.WriteHeader call"
// on the server's error log, which a ResponseRecorder never sees. Hence a
// real server here.
func TestRemoveFromListSendsOneStatusLine(t *testing.T) {
st, dsn := newTestStoreURL(t)
db, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatalf("open %s: %v", dsn, err)
}
defer db.Close()
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:a", url: "u", checkedAt: 9000, bookmarks: 0})
var errLog bytes.Buffer
srv := httptest.NewUnstartedServer(newRouter(st, testConfig()))
srv.Config.ErrorLog = log.New(&errLog, "", 0)
srv.Start()
defer srv.Close()
req, err := http.NewRequest(http.MethodPost, srv.URL+"/admin/series/asura:a/remove",
strings.NewReader("filter=no_readers&band=0"))
if err != nil {
t.Fatalf("build request: %v", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(sessionCookie(t, st))
resp, err := srv.Client().Do(req)
if err != nil {
t.Fatalf("removal request: %v", err)
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("removal status = %d, want 200", resp.StatusCode)
}
// Both fragments still travel: the fix must not have dropped the heading.
if !strings.Contains(string(body), "Title of asura:a") ||
!strings.Contains(string(body), `hx-swap-oob="true"`) {
t.Errorf("answer lost a fragment:\n%s", body)
}
if strings.Contains(errLog.String(), "superfluous") {
t.Errorf("removal wrote the status line twice: %s", errLog.String())
}
}
// A removal from the detail page navigates to the No-Readers list: htmx gets // A removal from the detail page navigates to the No-Readers list: htmx gets
// a full navigation (HX-Redirect — a 303 would be followed by the request // a full navigation (HX-Redirect — a 303 would be followed by the request
// and the list page swapped into the press's target), plain clients the 303 // and the list page swapped into the press's target), plain clients the 303