oficina / slides Public

fix: decks review round 1 — settings row and document commit together

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01563XGCgxcSZToEXwuTBsKE
claude agent, operated by paul@keymail.dev pushed by paul@keymail.dev 6823fbcd06358dbae6e51914bf1d2d80ac94fc89 parent cf0b7d5
9 files changed, +159 −30
  • cmd/slides/main.go +5 −1
  • internal/slides/app.go +13 −5
  • internal/slides/decks.go +32 −19
  • internal/slides/decks_test.go +79 −0
  • internal/slides/handlers.go +19 −0
  • internal/slides/sample.go +1 −1
  • internal/slidestest/browser_test.go +2 −1
  • internal/slidestest/harness_test.go +4 −1
  • internal/slidestest/signin_test.go +4 −2

Intent

Human

<task-notification> <task-id>aa9b6322b4f4b353d</task-id> <tool-use-id>toolu_01RhQxzjcHAt8NmS9nkTgZ9M</tool-use-id> <output-file>/home/paulca/.cache/tmp/claude-1001/-home-paulca-amadan-net-oficina/2420c44b-a27a-4e0d-bbe7-8b3ca0464c21/tasks/aa9b6322b4f4b353d.output</output-file> <status>completed</status> <summary>Agent "Implement Task 3: decks" finished</summary> <note>A task-notification fires each time this agent stops with no live background children of its own. The user can send it another message and resume it, so the same task-id may notify more than once.</note> <result>**Status:** Done. `make ci` green, nothing pushed. **Commit:** `cf0b7d5` — feat: decks with persistence, list, settings and a sample deck **Tests:** 5 new slidestest cases + 3 in `internal/slides/deck` + 2 assertions added to `auth_test.go`; all pass, whole suite green (vet, gofmt, tests, `migration check`). **Concerns:** - The deck list opens one wasm module per deck to count slides — deliberate (no denormalised count column per the brief's model), fine at small deck counts, and task 6's hub is the fix. - No snapshots yet: `openDeck` replays the whole log; trigger noted in its doc comment. - `saveDeckSettings` writes the document then the row in two transactions — a crash between leaves the list stale (the better half of the trade, but a window). - Deviations from the brief, all documented in code: `openDeck`/`applyOps` take the `Deck` (and `applyOps` the open handle) because the Y.Doc guid is the sqid; the sample is eight slides with `blank` riding on the two canvas slides (seven templates plus two canvas would be nine); `deck.Deck` gained `Meta`/`Fonts`/`Asset` from the core's view, which is the contract; migration is named `0002_create_decks.sql` by the generator. - A parallel agent committed to `core/` in this same worktree while I worked; I staged my files by name, nothing of theirs is in my commit. **Report:** `/home/paulca/amadan.net/oficina/slides/.claude/worktrees/slice-1/.superpowers/sdd/2026-09-07-slice-1-live/task-3-report.md`</result> <usage><subagent_tokens>253995</subagent_tokens><tool_uses>120</tool_uses><duration_ms>1214459</duration_ms></usage> </task-notification>

Human

<task-notification> <task-id>a37efb2cc0c49bb94</task-id> <tool-use-id>toolu_018mZvTgwoo6fhwrNWkEdpTF</tool-use-id> <output-file>/home/paulca/.cache/tmp/claude-1001/-home-paulca-amadan-net-oficina/2420c44b-a27a-4e0d-bbe7-8b3ca0464c21/tasks/a37efb2cc0c49bb94.output</output-file> <status>completed</status> <summary>Agent "Review Task 3 (spec + quality)" finished</summary> <note>A task-notification fires each time this agent stops with no live background children of its own. The user can send it another message and resume it, so the same task-id may notify more than once.</note> <result>### Verdict: **Approved** Task 3 delivers decks persistence, list/create/settings/delete screens, and the sample deck, all matching the brief's interfaces and the controller decisions given for this review (sqid: 10 chars over the given alphabet, crypto/rand, 5 retries; slide/block ids: 12 chars over the given base32 alphabet; Y.Doc guid = sqid; server client id 1; one `*core.Runtime` on `app`; `deck.Deck` gaining `Meta`/`Fonts`/`Asset` correctly mirrors `core/src/view.rs`, which I read directly and confirmed field-for-field). Tests exercise real behaviour, not just status codes. ### Spec Compliance - §2 model: `deck.Deck`/`Slide`/`Block`/`Text`/`Run`/`Box`/`PhoneOverride` match `core/src/view.rs`'s emitted JSON exactly (`internal/slides/deck/deck.go:1-179`, verified against `core/src/view.rs:34-96`). The op vocabulary used in `decks.go`/`sample.go` (`set_meta`, `add_slide`, `add_block`, `set_notes`, `set_slide`, `set_phone`) matches `core/src/ops.rs`'s `Op` enum field-for-field, including `markdown` on `AddBlock`. - §5 rungs: every deck starts `private` (`decks.go:1050` region, `createDeckRow`); `visibleDecks` (`decks.go` "the visibility seam") ORs owner, team, and grant — `granted` is honoured even though unreachable via UI, as the brief allows. - §6.1/6.2: `sessions.Current(r).Subject` (email) mapped via `currentUser`→`ensureUser`, never `UserID`; every write's actor is `person:&lt;id&gt;` via `actorFor` (`decks.go`). - Global seam constraint: not-found and not-yours both 404 via `deckFor`/`ownedDeckFor` sharing `a.notFound`; writes require owner (`ownedDeckFor`), confirmed by `TestForeignPrivateDeckIs404` and the settings/delete "stranger→404" assertions in `TestTeamDeckVisibleToOthers`/`TestRenameAndDelete`. - csrf/session wiring: `csrf.Protect(a.cfg.Origin)` wraps the whole router (`app.go:39`); every new mutating route is POST inside the `RequireSession` group (`app.go:57-72`). - Transaction correctness (checked against rastrillo §3, the named risk): `applyOps` and `deleteDeck` both use the callback's `tx`, never `a.db.G`/`d.G`, inside `Transaction(...)` (`decks.go`). `h.JSON` was correctly moved outside the write transaction (self-reported fix, confirmed present in the diff). - `class="rst-mono"` in `deck.html` is **not** a violation: tokens.css explicitly documents utilities (`rst-mono` among them) as "keep the class spelling and are not paired" — the bare-attribute rule applies to component kinds, not utilities (`internal/slides/static/tokens.css:43-45`). - Deviations (openDeck/applyOps taking `Deck`/`*Deck`+handle instead of `deckID`; eight-slide sample with `blank` on both canvas slides; `deck.Deck` gaining `Meta`/`Fonts`/`Asset`; migration filename) are all reasonable and correctly justified against the brief and the "core is the contract" rule — none conflict with a hard constraint. ### Strengths - The visibility seam is genuinely single-sourced and defended with a comment explaining why the OR clause must be parenthesised — a real bug class avoided. - `editDeck`'s retry-on-`errDeckBusy` correctly reopens a fresh handle rather than retrying on the stale one, avoiding double-application of ops after a rolled-back transaction — I traced this and it's correct. - `Block.UnmarshalJSON`'s fractional-`z` tolerance is a well-reasoned, narrowly-scoped repair with a clear justification (CRDT peers can insert fractional z), and it's exactly the one place the package says it repairs anything. - Test coverage exercises cross-cutting behaviour that matters: rename reaching both row and document, delete cascading to updates/grants, empty-list state, and 404-not-403 semantics for both reads and writes. - Self-review section (h.JSON out of transaction, per-row degrade on `slideCount`, `rst-box-head` nesting fix) shows real verification happened before commit, not just claimed. ### Issues #### Critical (Must Fix) None. #### Important (Should Fix) - **`saveDeckSettings` writes the document then the row in two non-atomic steps** (`decks.go`, `saveDeckSettings`): a crash or error between `editDeck` and the row `Updates` call leaves the list showing a stale title while the document (and any concurrent viewer of `GET /d/{sqid}`) shows the new one — the opposite of the "list promises a name the deck doesn't have" case the author optimized against, but still a real, unhandled divergence with no repair path. Acknowledged by the author as "a window"; worth a follow-up ticket rather than blocking this task, since fixing it properly needs either a single source of truth for title/theme or a reconciliation step outside this task's scope. #### Minor (Nice to Have) - `decks.go` is 1007 lines covering ids, the seam, document ops, and six screens in one file; the header comment justifies the grouping, but a future split (e.g. screens vs. document/seam plumbing) would ease navigation as slice 2+ grows it further. - The process never closes `core.Runtime` (`app.go`), and each test's `newApp` builds a fresh one — acknowledged by the author as harmless today; worth a `Close` seam before this pattern multiplies across more test files. - `slides.deck.theme.invalid` is unreachable through the browser (native `&lt;select&gt;` can't produce a bad value) — correctly defended as "the handler must not trust the body," not a real issue, just noting it's untested by an integration test (only reachable via a hand-crafted POST). ### Assessment **Task quality:** Approved **Reasoning:** Every hard constraint in the task brief and the global rules (seam, 404 semantics, actor recording, transaction scoping to `tx`, CSRF/session placement, catalog strings, rst-* grammar, sqid/id schemes, one `*core.Runtime`) is met and verifiable in the diff. The one real gap — the two-phase, non-atomic settings write — is a narrow, acknowledged edge case with a defensible ordering choice, not a correctness or security defect, and doesn't block this task; it's a good candidate for the reviewer's punch-list on the next task rather than a blocker here.</result> <usage><subagent_tokens>128256</subagent_tokens><tool_uses>31</tool_uses><duration_ms>215973</duration_ms></usage> </task-notification>

Attributed to this commit: 14.0 g CO₂e (1782293 tokens). This is an attribution, not a measurement — usage counted from this commit's own marker forward to the next one on worktree-slice-1, never backward from it. A turn that produced more than one commit books all of its tokens to the last one, and a concurrent session on this branch can add to the same figure. Some of it was priced using the shared default rate, because no model-specific factor row exists yet for at least one model involved. The Carbon view has the repo total, which is never derived from this attribution.

diff --git a/cmd/slides/main.go b/cmd/slides/main.go
index 1e6db24..09f4dfe 100644
--- a/cmd/slides/main.go
+++ b/cmd/slides/main.go
@@ -222,7 +222,7 @@ func main() {
// Every empty value below is a startup refusal inside slides.New
// unless env is "development" — see config.go's package comment.
- mux, err := slides.New(d, slides.Config{
+ mux, a, err := slides.New(d, slides.Config{
Origin: origin,
Env: env,
InstanceKey: os.Getenv("SLIDES_INSTANCE_KEY"),
@@ -238,6 +238,10 @@ func main() {
logger.Error("build app", "err", err)
os.Exit(1)
}
+ // Releases the compiled deck core's wazero runtime on the way out —
+ // after rastrillo.Serve returns (a signal, or a fatal error), same
+ // as d.Close() above.
+ defer a.Close()
opts.Mux = mux
opts.DBPath = ""
diff --git a/internal/slides/app.go b/internal/slides/app.go
index 2efcc5e..c86a7db 100644
--- a/internal/slides/app.go
+++ b/internal/slides/app.go
@@ -28,10 +28,17 @@ import (
// The database handle is the caller's (cmd/slides/main.go's db.Open),
// not one this opens: SKILL.md §1's Resolve/Serve split depends on
// exactly one connection to the file.
-func New(d *db.DB, cfg Config) (*http.ServeMux, error) {
+//
+// The second return value is the built *app, unexported so callers
+// outside this package can only ever hold one (via :=) and call its
+// exported methods — today, just Close, which releases the deck core's
+// runtime. Returning it is what lets cmd/slides/main.go and the test
+// harness release that resource on shutdown without this package
+// exposing anything more of the app than it already did.
+func New(d *db.DB, cfg Config) (*http.ServeMux, *app, error) {
a, authPlugin, err := newApp(d, cfg)
if err != nil {
- return nil, err
+ return nil, nil, err
}
r := chi.NewRouter()
@@ -82,7 +89,7 @@ func New(d *db.DB, cfg Config) (*http.ServeMux, error) {
// so the sign-in page can load its stylesheet signed out.
mux.Handle("GET /static/", assets.Handler())
mux.Handle("/", r)
- return mux, nil
+ return mux, a, nil
}
// newApp is everything New does before the router: resolve the config
@@ -91,8 +98,9 @@ func New(d *db.DB, cfg Config) (*http.ServeMux, error) {
// Separate from New so this package's own tests can assert on the
// resolved *app — that a.cfg carries the origin the app is really
// protecting rather than the empty string a development caller passed
-// in (config_test.go). New itself hands back only a mux, which cannot
-// answer that question.
+// in (config_test.go). New's own *app return is unexported, so a
+// caller outside this package can hold one and Close it but never
+// spell its type or read a.cfg; only a package-internal test can.
func newApp(d *db.DB, cfg Config) (*app, *rauth.Auth, error) {
// Fail closed, loudly, and before anything opens: Origin,
// InstanceKey, Mailer and AllowedAddresses each decide whether
diff --git a/internal/slides/decks.go b/internal/slides/decks.go
index e0831cb..392f36e 100644
--- a/internal/slides/decks.go
+++ b/internal/slides/decks.go
@@ -242,7 +242,18 @@ var errDeckBusy = errors.New("slides: the deck moved while this update was being
//
// d.Seq is updated in place on success so a caller applying two batches
// in a row does not have to re-read the row.
-func (a *app) applyOps(ctx context.Context, h *core.Handle, d *Deck, actor string, ops []byte) (*deck.Deck, error) {
+//
+// rowUpdates is the row's own columns (title, theme, rung — the ones a
+// list or a link needs) to change alongside the document, in the SAME
+// transaction as the update-log insert. Nil for every caller that only
+// touches the document. Merged into the same `WHERE id = ? AND seq = ?`
+// Updates call the seq bump already makes: that call is the optimistic
+// lock, so folding the row's other columns into it costs nothing extra
+// and means the row and the log commit or roll back together — a
+// failure applying rowUpdates (a rejected rung, a constraint) now rolls
+// back h.Ops' own DeckUpdate row too, rather than leaving the row
+// diverged from the document's meta.
+func (a *app) applyOps(ctx context.Context, h *core.Handle, d *Deck, actor string, ops []byte, rowUpdates map[string]any) (*deck.Deck, error) {
err := a.db.G.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
update, err := h.Ops(ctx, ops)
if err != nil {
@@ -260,8 +271,16 @@ func (a *app) applyOps(ctx context.Context, h *core.Handle, d *Deck, actor strin
return fmt.Errorf("slides: record deck %s update %d: %w", d.Sqid, next, err)
}
+ // Named columns off a map, never a bound struct (SKILL.md §4):
+ // "seq" is always present so the WHERE ... AND seq = ? clause
+ // above stays the sole optimistic lock, whether or not this
+ // call is also renaming the deck.
+ fields := map[string]any{"seq": next}
+ for k, v := range rowUpdates {
+ fields[k] = v
+ }
res := tx.Model(&Deck{}).Where("id = ? AND seq = ?", d.ID, d.Seq).
- Updates(map[string]any{"seq": next})
+ Updates(fields)
if res.Error != nil {
return fmt.Errorf("slides: bump deck %s seq: %w", d.Sqid, res.Error)
}
@@ -302,14 +321,14 @@ func (a *app) applyOps(ctx context.Context, h *core.Handle, d *Deck, actor strin
// document — the batch applied nothing, so replaying it against the
// state that beat us is the correct thing to do, and a second conflict
// means real contention rather than a race worth spinning on.
-func (a *app) editDeck(ctx context.Context, d *Deck, actor string, ops []byte) error {
+func (a *app) editDeck(ctx context.Context, d *Deck, actor string, ops []byte, rowUpdates map[string]any) error {
var lastErr error
for attempt := 0; attempt < 2; attempt++ {
h, _, err := a.openDeck(ctx, *d)
if err != nil {
return err
}
- _, err = a.applyOps(ctx, h, d, actor, ops)
+ _, err = a.applyOps(ctx, h, d, actor, ops, rowUpdates)
h.Close(ctx)
if err == nil {
return nil
@@ -713,7 +732,7 @@ func (a *app) createDeck(w http.ResponseWriter, r *http.Request) {
a.fail(w, r, "build the opening ops", err)
return
}
- if err := a.editDeck(r.Context(), &d, actorFor(u), ops); err != nil {
+ if err := a.editDeck(r.Context(), &d, actorFor(u), ops, nil); err != nil {
a.fail(w, r, "write the opening ops", err)
return
}
@@ -881,10 +900,12 @@ func (a *app) deckSettings(w http.ResponseWriter, r *http.Request) {
// saveDeckSettings serves POST /d/{sqid}/settings.
//
// Title and theme live in two places — the row the list reads and the
-// document everyone edits — so both are written. The document goes
-// first: it is what the deck IS, and a crash between the two writes
-// should leave the list stale rather than leave the list promising a
-// name the deck does not have.
+// document everyone edits — so both are written, and both go through
+// editDeck's rowUpdates so they land in the SAME transaction as the
+// update-log insert applyOps makes. A rejected row write (a rung a
+// constraint refuses, say) now rolls the document change back too,
+// rather than leaving the row's title/theme diverged from what the
+// document's meta says — the earlier two-transaction shape's window.
func (a *app) saveDeckSettings(w http.ResponseWriter, r *http.Request) {
u, ok := a.viewer(w, r)
if !ok {
@@ -920,19 +941,11 @@ func (a *app) saveDeckSettings(w http.ResponseWriter, r *http.Request) {
a.fail(w, r, "build the settings ops", err)
return
}
- if err := a.editDeck(r.Context(), &d, actorFor(u), ops); err != nil {
+ rowUpdates := map[string]any{"title": title, "theme": theme, "rung": rung}
+ if err := a.editDeck(r.Context(), &d, actorFor(u), ops, rowUpdates); err != nil {
a.fail(w, r, "write the settings ops", err)
return
}
- // Named columns, never a bound struct (SKILL.md §4), and scoped to
- // this owner's deck so the write carries the same condition the
- // read did.
- if err := a.db.G.WithContext(r.Context()).Model(&Deck{}).
- Where("id = ? AND owner_id = ?", d.ID, u.ID).
- Updates(map[string]any{"title": title, "theme": theme, "rung": rung}).Error; err != nil {
- a.fail(w, r, "save deck settings", err)
- return
- }
http.Redirect(w, r, "/d/"+d.Sqid, http.StatusSeeOther)
}
diff --git a/internal/slides/decks_test.go b/internal/slides/decks_test.go
new file mode 100644
index 0000000..e6046d1
--- /dev/null
+++ b/internal/slides/decks_test.go
@@ -0,0 +1,79 @@
+package slides
+
+import (
+ "context"
+ "testing"
+)
+
+// TestSettingsRowAndDocumentChangeTogether is the regression test for a
+// real bug: saveDeckSettings used to write the document via editDeck
+// and then the decks row via a second, separate Updates call — two
+// transactions, so a failure between them left the row's title/theme
+// diverged from what the document's own meta said.
+//
+// The fix folds the row write into applyOps' one transaction via
+// editDeck's rowUpdates: it lands in the same Updates call the seq
+// bump already makes, under the same `WHERE id = ? AND seq = ?` lock,
+// so it commits or rolls back with the update-log insert. This proves
+// the "or rolls back" half by making the row write fail (an unknown
+// column — the particular reason doesn't matter, only that it is a
+// failure applyOps' transaction sees) and checking that BOTH the row
+// and the document come out unchanged, seq included.
+func TestSettingsRowAndDocumentChangeTogether(t *testing.T) {
+ d := testDB(t)
+ a, _, err := newApp(d, Config{Env: EnvDevelopment, Logger: quietLogger()})
+ if err != nil {
+ t.Fatalf("newApp: %v", err)
+ }
+ t.Cleanup(func() { a.Close() })
+ ctx := context.Background()
+
+ owner, _, err := ensureUser(ctx, d.G, "paul@example.test")
+ if err != nil {
+ t.Fatalf("ensureUser: %v", err)
+ }
+
+ row, err := a.createDeckRow(ctx, owner, "Original title", defaultTheme)
+ if err != nil {
+ t.Fatalf("createDeckRow: %v", err)
+ }
+ ops, err := openingOps("Original title", defaultTheme)
+ if err != nil {
+ t.Fatalf("openingOps: %v", err)
+ }
+ if err := a.editDeck(ctx, &row, actorFor(owner), ops, nil); err != nil {
+ t.Fatalf("seed editDeck: %v", err)
+ }
+ seqBefore := row.Seq
+
+ renameOps, err := opsJSON([]op{{"op": "set_meta", "title": "New title", "theme": defaultTheme}})
+ if err != nil {
+ t.Fatalf("opsJSON: %v", err)
+ }
+ // No column named this, so the Updates call inside applyOps' own
+ // transaction fails — the row write, not h.Ops, is what breaks.
+ badRowUpdates := map[string]any{"no_such_column": "boom"}
+ if err := a.editDeck(ctx, &row, actorFor(owner), renameOps, badRowUpdates); err == nil {
+ t.Fatal("editDeck with an invalid row update returned no error")
+ }
+
+ var reloaded Deck
+ if err := d.G.First(&reloaded, row.ID).Error; err != nil {
+ t.Fatalf("reload deck row: %v", err)
+ }
+ if reloaded.Title != "Original title" {
+ t.Errorf("deck row title = %q after a failed settings write, want %q (unchanged)", reloaded.Title, "Original title")
+ }
+ if reloaded.Seq != seqBefore {
+ t.Errorf("deck row seq = %d after a failed settings write, want %d (unchanged)", reloaded.Seq, seqBefore)
+ }
+
+ h, view, err := a.openDeck(ctx, reloaded)
+ if err != nil {
+ t.Fatalf("openDeck: %v", err)
+ }
+ defer h.Close(ctx)
+ if view.Meta.Title != "Original title" {
+ t.Errorf("document meta title = %q after a failed settings write, want %q (unchanged)", view.Meta.Title, "Original title")
+ }
+}
diff --git a/internal/slides/handlers.go b/internal/slides/handlers.go
index e051dd4..ee9ca5d 100644
--- a/internal/slides/handlers.go
+++ b/internal/slides/handlers.go
@@ -1,6 +1,7 @@
package slides
import (
+ "context"
"log/slog"
"amadan.net/rastrillo/rastrillo/db"
@@ -32,3 +33,21 @@ type app struct {
// broadcasts to open browsers; every caller is nil-safe.
onUpdate func(deckID, seq int64)
}
+
+// Close releases the compiled deck core's wazero runtime — the one
+// resource New builds for itself rather than taking from its caller
+// (newApp's doc comment: the database handle is the caller's, closed
+// there). It does not close the database: New never opened it, so it
+// is never this method's to release.
+//
+// New hands back the *app this closes alongside the mux it also
+// returns; a caller that never stops (a long-running server whose
+// process exit reclaims everything anyway) is free to ignore it, but
+// cmd/slides/main.go's shutdown path and every test harness call it so
+// a compiled module is not leaked per app built.
+func (a *app) Close() error {
+ if a == nil || a.core == nil {
+ return nil
+ }
+ return a.core.Close(context.Background())
+}
diff --git a/internal/slides/sample.go b/internal/slides/sample.go
index 4607898..9fc6f83 100644
--- a/internal/slides/sample.go
+++ b/internal/slides/sample.go
@@ -45,7 +45,7 @@ func (a *app) seedSampleDeck(ctx context.Context, u User) error {
if err != nil {
return err
}
- return a.editDeck(ctx, &d, actorFor(u), encoded)
+ return a.editDeck(ctx, &d, actorFor(u), encoded, nil)
}
// slideIDs mints the ids one slide's ops need: the slide's own and n
diff --git a/internal/slidestest/browser_test.go b/internal/slidestest/browser_test.go
index a085bb6..c101a9c 100644
--- a/internal/slidestest/browser_test.go
+++ b/internal/slidestest/browser_test.go
@@ -43,7 +43,7 @@ func TestBrowserWalk(t *testing.T) {
t.Fatalf("db.Open: %v", err)
}
t.Cleanup(func() { d.Close() })
- mux, err := slides.New(d, slides.Config{
+ mux, a, err := slides.New(d, slides.Config{
Origin: origin,
InstanceKey: testInstanceKey,
Mailer: &fakeMailer{},
@@ -53,6 +53,7 @@ func TestBrowserWalk(t *testing.T) {
if err != nil {
t.Fatalf("slides.New: %v", err)
}
+ t.Cleanup(func() { a.Close() })
return localised(t, mux)
})
rig.Run(chromedp.Navigate(rig.Origin + "/signin"))
diff --git a/internal/slidestest/harness_test.go b/internal/slidestest/harness_test.go
index b74a492..2217c61 100644
--- a/internal/slidestest/harness_test.go
+++ b/internal/slidestest/harness_test.go
@@ -114,10 +114,13 @@ func newApp(t *testing.T) *testApp {
func newAppWith(t *testing.T, cfg slides.Config, mailer *fakeMailer) *testApp {
t.Helper()
d := openTestDB(t, cfg)
- mux, err := slides.New(d, cfg)
+ mux, a, err := slides.New(d, cfg)
if err != nil {
t.Fatalf("slides.New: %v", err)
}
+ // Releases the compiled deck core's wazero runtime; every test app
+ // built over slides.New leaks one without this (task 3 fix round 1).
+ t.Cleanup(func() { a.Close() })
return &testApp{Handler: localised(t, mux), mailer: mailer, db: d}
}
diff --git a/internal/slidestest/signin_test.go b/internal/slidestest/signin_test.go
index dbe5ccb..84e4f5e 100644
--- a/internal/slidestest/signin_test.go
+++ b/internal/slidestest/signin_test.go
@@ -255,7 +255,7 @@ func TestDevelopmentDefaultsRefusedInProduction(t *testing.T) {
cfg, _ := newConfig(t)
cfg.Env = "production"
tc.bend(&cfg)
- if _, err := slides.New(openTestDB(t, cfg), cfg); err == nil {
+ if _, _, err := slides.New(openTestDB(t, cfg), cfg); err == nil {
t.Fatalf("slides.New with an empty %s and Env %q returned no error; want a startup refusal", tc.name, cfg.Env)
}
})
@@ -272,7 +272,9 @@ func TestDevelopmentDefaultsAllowedInDevelopment(t *testing.T) {
cfg.Origin = ""
cfg.Mailer = nil
cfg.AllowedAddresses = nil
- if _, err := slides.New(openTestDB(t, cfg), cfg); err != nil {
+ _, a, err := slides.New(openTestDB(t, cfg), cfg)
+ if err != nil {
t.Fatalf("slides.New with an empty config and Env %q: %v", cfg.Env, err)
}
+ t.Cleanup(func() { a.Close() })
}