summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-04-27 09:18:52 +0300
committerPaul Buetow <paul@buetow.org>2026-04-27 09:18:52 +0300
commit47d69cb998a447eea662ad1075f9d002dd875443 (patch)
tree1fa586fc4f7e5fae2ce9badfa05679c0fee9dcb3
parent626ff3ae7d43cfc2ec3f2554d340b40f4a5c0586 (diff)
Add context.Context to I/O-bound public APIs (generator.Run, processor.Run, atom.Generate, syncOutput)
- generator.Run(ctx, cfg) – ctx passed through to atom.Generate - processor.Run(ctx, cfg) – signature updated for cancellation propagation - atom.Generate(ctx, posts, cfg) – accepts ctx for future cancellation - syncOutput(ctx, cfg) – rsync subprocesses now use exec.CommandContext - Updated all call sites in tests, cmd/snonux/main.go, and integration tests - All call sites pass context.Background() / context.TODO() All tests pass: go test ./...
-rw-r--r--cmd/snonux/main.go13
-rw-r--r--cmd/snonux/main_test.go5
-rw-r--r--cmd/snonux/sync.go6
-rw-r--r--cmd/snonux/sync_test.go4
-rw-r--r--integrationtests/integration_test.go11
-rw-r--r--internal/generator/atom/atom.go5
-rw-r--r--internal/generator/atom/atom_test.go9
-rw-r--r--internal/generator/generator.go7
-rw-r--r--internal/generator/generator_test.go5
-rw-r--r--internal/generator/theme_sounds.go3
-rw-r--r--internal/processor/markdown_test.go4
-rw-r--r--internal/processor/processor.go20
-rw-r--r--internal/processor/processor_test.go21
13 files changed, 72 insertions, 41 deletions
diff --git a/cmd/snonux/main.go b/cmd/snonux/main.go
index 659bd19..853d807 100644
--- a/cmd/snonux/main.go
+++ b/cmd/snonux/main.go
@@ -8,6 +8,7 @@
package main
import (
+ "context"
"errors"
"flag"
"fmt"
@@ -61,12 +62,14 @@ func main() {
log.Fatalf("error: %v", err)
}
- if err := run(cfg); err != nil {
+ ctx := context.Background()
+
+ if err := run(ctx, cfg); err != nil {
log.Fatalf("error: %v", err)
}
if cfg.Sync {
- if err := syncOutput(cfg); err != nil {
+ if err := syncOutput(ctx, cfg); err != nil {
log.Fatalf("error: %v", err)
}
}
@@ -130,15 +133,15 @@ func expandHome(path string) (string, error) {
}
// run executes both pipeline phases: process inputs, then regenerate pages.
-func run(cfg *config.Config) error {
- processed, err := processor.Run(cfg)
+func run(ctx context.Context, cfg *config.Config) error {
+ processed, err := processor.Run(ctx, cfg)
if err != nil {
return fmt.Errorf("processing input files: %w", err)
}
log.Printf("processed %d new post(s) from %s", processed, cfg.InputDir)
- if err := generator.Run(cfg); err != nil {
+ if err := generator.Run(ctx, cfg); err != nil {
return fmt.Errorf("generating site: %w", err)
}
diff --git a/cmd/snonux/main_test.go b/cmd/snonux/main_test.go
index b8a5a82..06cbc1c 100644
--- a/cmd/snonux/main_test.go
+++ b/cmd/snonux/main_test.go
@@ -1,6 +1,7 @@
package main
import (
+ "context"
"errors"
"math/rand"
"os"
@@ -12,6 +13,8 @@ import (
"codeberg.org/snonux/snonux/internal/generator"
)
+var ctx = context.Background() //nolint:gochecknoglobals // test-only top-level helper used by every test in the file
+
func TestExpandHome(t *testing.T) {
t.Parallel()
@@ -291,7 +294,7 @@ func TestRun_pipeline(t *testing.T) {
BaseURL: "https://pipe.test",
Theme: "neon",
}
- if err := run(cfg); err != nil {
+ if err := run(ctx, cfg); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(filepath.Join(out, "index.html"))
diff --git a/cmd/snonux/sync.go b/cmd/snonux/sync.go
index f5d2e9e..3e4155e 100644
--- a/cmd/snonux/sync.go
+++ b/cmd/snonux/sync.go
@@ -58,7 +58,9 @@ func splitAndTrim(s string) []string {
// syncOutput rsyncs localOutput (trailing-slash source) to each sync target over SSH
// port 22. It runs only if every target answers ICMP ping (Linux iputils: ping -c 1 -W …).
-func syncOutput(cfg *config.Config) error {
+// The ctx parameter is accepted for cancellation propagation; it is wired into
+// exec.CommandContext for the rsync subprocesses.
+func syncOutput(ctx context.Context, cfg *config.Config) error {
resolveSyncConfig(cfg)
for _, host := range cfg.SyncTargets {
@@ -87,7 +89,7 @@ func syncOutput(cfg *config.Config) error {
for _, host := range cfg.SyncTargets {
dest := fmt.Sprintf("%s@%s:%s", sshUser, host, cfg.SyncRemoteDir)
log.Printf("rsync %s -> %s", src, dest)
- cmd := exec.Command("rsync", "-az", "-e", ssh, src, dest)
+ cmd := exec.CommandContext(ctx, "rsync", "-az", "-e", ssh, src, dest)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
diff --git a/cmd/snonux/sync_test.go b/cmd/snonux/sync_test.go
index d45a916..93c9233 100644
--- a/cmd/snonux/sync_test.go
+++ b/cmd/snonux/sync_test.go
@@ -87,8 +87,8 @@ func TestResolveSyncConfig_flagsOverrideEnv(t *testing.T) {
os.Setenv("SNONUX_SYNC_REMOTE_DIR", "/env/dir/")
cfg := &config.Config{
- SyncTargets: []string{"from-flag"},
- SyncRemoteDir: "/flag/dir/",
+ SyncTargets: []string{"from-flag"},
+ SyncRemoteDir: "/flag/dir/",
}
resolveSyncConfig(cfg)
diff --git a/integrationtests/integration_test.go b/integrationtests/integration_test.go
index 9beaf29..e3737f4 100644
--- a/integrationtests/integration_test.go
+++ b/integrationtests/integration_test.go
@@ -4,6 +4,7 @@
package integrationtests
import (
+ "context"
"encoding/json"
"encoding/xml"
"fmt"
@@ -23,6 +24,8 @@ import (
"codeberg.org/snonux/snonux/internal/processor"
)
+var ctx = context.Background() //nolint:gochecknoglobals // test-only top-level helper used by every test in the file
+
// runPipeline executes both pipeline stages and returns the config used.
func runPipeline(t *testing.T, inputDir, outputDir string) *config.Config {
t.Helper()
@@ -34,12 +37,12 @@ func runPipeline(t *testing.T, inputDir, outputDir string) *config.Config {
Theme: "neon",
}
- _, err := processor.Run(cfg)
+ _, err := processor.Run(ctx, cfg)
if err != nil {
t.Fatalf("processor.Run: %v", err)
}
- if err := generator.Run(cfg); err != nil {
+ if err := generator.Run(ctx, cfg); err != nil {
t.Fatalf("generator.Run: %v", err)
}
@@ -427,10 +430,10 @@ func TestThemeSelection(t *testing.T) {
Theme: theme,
}
- if _, err := processor.Run(cfg); err != nil {
+ if _, err := processor.Run(ctx, cfg); err != nil {
t.Fatalf("processor.Run: %v", err)
}
- if err := generator.Run(cfg); err != nil {
+ if err := generator.Run(ctx, cfg); err != nil {
t.Fatalf("generator.Run for theme %q: %v", theme, err)
}
diff --git a/internal/generator/atom/atom.go b/internal/generator/atom/atom.go
index 75f4876..03475d8 100644
--- a/internal/generator/atom/atom.go
+++ b/internal/generator/atom/atom.go
@@ -5,6 +5,7 @@
package atom
import (
+ "context"
"encoding/xml"
"fmt"
"os"
@@ -48,7 +49,9 @@ type content struct {
// Generate writes atom.xml to cfg.OutputDir containing the most recent
// min(len(posts), config.PostsPerPage) entries. Posts must already be sorted
// newest-first (as produced by generator.Run).
-func Generate(posts []*post.Post, cfg *config.Config) error {
+// The context is currently accepted for API consistency and future
+// cancellation propagation; no blocking I/O operations currently observe it.
+func Generate(ctx context.Context, posts []*post.Post, cfg *config.Config) error {
limit := config.PostsPerPage
if len(posts) < limit {
limit = len(posts)
diff --git a/internal/generator/atom/atom_test.go b/internal/generator/atom/atom_test.go
index 2bfdfb7..04a33fb 100644
--- a/internal/generator/atom/atom_test.go
+++ b/internal/generator/atom/atom_test.go
@@ -1,6 +1,7 @@
package atom
import (
+ "context"
"encoding/xml"
"os"
"path/filepath"
@@ -12,6 +13,8 @@ import (
"codeberg.org/snonux/snonux/internal/post"
)
+var ctx = context.Background() //nolint:gochecknoglobals // test-only top-level helper used by every test in the file
+
func TestGenerate_writesAtomXML(t *testing.T) {
t.Parallel()
@@ -28,7 +31,7 @@ func TestGenerate_writesAtomXML(t *testing.T) {
},
}
- if err := Generate(posts, cfg); err != nil {
+ if err := Generate(ctx, posts, cfg); err != nil {
t.Fatalf("Generate: %v", err)
}
@@ -54,7 +57,7 @@ func TestGenerate_emptyPosts(t *testing.T) {
dir := t.TempDir()
cfg := &config.Config{OutputDir: dir, BaseURL: "https://x.test"}
- if err := Generate(nil, cfg); err != nil {
+ if err := Generate(ctx, nil, cfg); err != nil {
t.Fatalf("Generate: %v", err)
}
@@ -90,7 +93,7 @@ func TestGenerate_limitPostsPerPage(t *testing.T) {
})
}
- if err := Generate(posts, cfg); err != nil {
+ if err := Generate(ctx, posts, cfg); err != nil {
t.Fatalf("Generate: %v", err)
}
diff --git a/internal/generator/generator.go b/internal/generator/generator.go
index db9531b..bd5b850 100644
--- a/internal/generator/generator.go
+++ b/internal/generator/generator.go
@@ -1,6 +1,7 @@
package generator
import (
+ "context"
"encoding/json"
"fmt"
"html/template"
@@ -81,7 +82,9 @@ func allThemesJSON() (template.JS, error) {
// Run loads all posts, generates all HTML pages, and writes atom.xml plus the
// shared CSS/JS bundles and per-theme asset files.
-func Run(cfg *config.Config) error {
+// The ctx parameter is accepted for cancellation propagation; it is passed
+// through to I/O-bound calls where possible.
+func Run(ctx context.Context, cfg *config.Config) error {
posts, err := loadAllPosts(cfg.OutputDir)
if err != nil {
return err
@@ -131,7 +134,7 @@ func Run(cfg *config.Config) error {
}
}
- return atom.Generate(posts, cfg)
+ return atom.Generate(ctx, posts, cfg)
}
// loadAllPosts walks outdir/posts/ and deserialises every post.json found.
diff --git a/internal/generator/generator_test.go b/internal/generator/generator_test.go
index 56542a3..50cd3d0 100644
--- a/internal/generator/generator_test.go
+++ b/internal/generator/generator_test.go
@@ -1,6 +1,7 @@
package generator
import (
+ "context"
"encoding/json"
"html/template"
"os"
@@ -13,6 +14,8 @@ import (
"codeberg.org/snonux/snonux/internal/post"
)
+var ctx = context.Background() //nolint:gochecknoglobals // test-only top-level helper used by every test in the file
+
func TestPageFilename(t *testing.T) {
t.Parallel()
@@ -428,7 +431,7 @@ func TestRun_writesPagesAndAtom(t *testing.T) {
BaseURL: "https://example.test",
Theme: "neon",
}
- if err := Run(cfg); err != nil {
+ if err := Run(ctx, cfg); err != nil {
t.Fatalf("Run: %v", err)
}
if _, err := os.Stat(filepath.Join(out, "index.html")); err != nil {
diff --git a/internal/generator/theme_sounds.go b/internal/generator/theme_sounds.go
index 20ffacc..234a964 100644
--- a/internal/generator/theme_sounds.go
+++ b/internal/generator/theme_sounds.go
@@ -112,7 +112,8 @@ func minor(freq float64) [3]float64 {
// into the preceding note's step so the next real note triggers later. We
// can't emit a zero-frequency rest entry because the JS engine substitutes
// 440 Hz for falsy freq values, which would turn rests into audible tones.
-// beat = seconds-per-quarter-note. dur multiplier of 1.0 = quarter note.
+//
+// beat = seconds-per-quarter-note. dur multiplier of 1.0 = quarter note.
func hook(beat float64, pairs ...float64) []melodyNote {
out := make([]melodyNote, 0, len(pairs)/2)
leadIn := 0.0
diff --git a/internal/processor/markdown_test.go b/internal/processor/markdown_test.go
index c53874d..b4582a9 100644
--- a/internal/processor/markdown_test.go
+++ b/internal/processor/markdown_test.go
@@ -1,6 +1,7 @@
package processor
import (
+ "context"
"os"
"path/filepath"
"runtime"
@@ -121,7 +122,6 @@ func TestFindLocalImages(t *testing.T) {
want: []string{"z.gif"},
wantLen: 1,
},
-
}
for _, tt := range tests {
@@ -178,7 +178,7 @@ func TestRun_UnreadableMarkdownPreScanFails(t *testing.T) {
t.Cleanup(func() { _ = os.Chmod(mdPath, 0o644) })
cfg := &config.Config{InputDir: inputDir, OutputDir: outputDir}
- _, err := Run(cfg)
+ _, err := Run(context.Background(), cfg)
if err == nil {
t.Fatal("Run: expected error when markdown pre-scan cannot read a .md file")
}
diff --git a/internal/processor/processor.go b/internal/processor/processor.go
index bb3a84d..0bbc28e 100644
--- a/internal/processor/processor.go
+++ b/internal/processor/processor.go
@@ -4,9 +4,10 @@
// Each processed source file is deleted from the input directory afterward.
//
// Processing uses a two-phase commit pattern:
-// 1. Scan and validate every inbox item without mutating anything.
-// 2. Only after all items pass validation, execute mutations
-// (create directories, write assets, persist posts, remove sources).
+// 1. Scan and validate every inbox item without mutating anything.
+// 2. Only after all items pass validation, execute mutations
+// (create directories, write assets, persist posts, remove sources).
+//
// If validation fails for any item, the entire batch is aborted and the inbox
// is left untouched. If a mutation fails mid-batch, earlier items have already
// been committed; the failing item is rolled back and the error is returned
@@ -21,6 +22,7 @@
package processor
import (
+ "context"
"fmt"
"image"
"os"
@@ -38,7 +40,7 @@ import (
// to the core planning or commit loops are required.
type PostBuilder interface {
// Plan validates the source file and returns everything needed to commit it later.
- Plan(srcPath string, ext string) (postPlan, error)
+ Plan(srcPath string, ext string) (postPlan, error)
// Commit performs the mutations for this post type and returns the populated Post,
// plus any extra inbox files that should be cleaned up after a successful save.
Commit(plan postPlan, postDir string, id string, now time.Time) (*post.Post, []string, error)
@@ -60,13 +62,15 @@ func register(ext string, b PostBuilder) {
// Run scans cfg.InputDir and processes every eligible file into a post directory
// under cfg.OutputDir/posts/. It uses a two-phase commit pattern:
//
-// Phase 1 — scan and validate all inbox items without mutating anything.
-// Phase 2 — only after all items pass validation, execute mutations
-// (create directories, write assets, persist posts, remove sources).
+// Phase 1 — scan and validate all inbox items without mutating anything.
+// Phase 2 — only after all items pass validation, execute mutations
+// (create directories, write assets, persist posts, remove sources).
//
// If Phase 1 fails for any item, no mutations occur and the inbox is left untouched.
// Returns the number of posts successfully created in this invocation.
-func Run(cfg *config.Config) (int, error) {
+// The ctx parameter is accepted for cancellation propagation; it is currently
+// wired through for API consistency.
+func Run(ctx context.Context, cfg *config.Config) (int, error) {
entries, err := os.ReadDir(cfg.InputDir)
if err != nil {
return 0, fmt.Errorf("read input dir %s: %w", cfg.InputDir, err)
diff --git a/internal/processor/processor_test.go b/internal/processor/processor_test.go
index 418fb95..b5e8f20 100644
--- a/internal/processor/processor_test.go
+++ b/internal/processor/processor_test.go
@@ -1,6 +1,7 @@
package processor
import (
+ "context"
"image"
"image/png"
"os"
@@ -13,6 +14,8 @@ import (
"codeberg.org/snonux/snonux/internal/post"
)
+var ctx = context.Background() //nolint:gochecknoglobals // test-only top-level helper used by every test in the file
+
func TestRun_processesTxt(t *testing.T) {
t.Parallel()
@@ -23,7 +26,7 @@ func TestRun_processesTxt(t *testing.T) {
}
cfg := &config.Config{InputDir: in, OutputDir: out, BaseURL: "https://x.test"}
- n, err := Run(cfg)
+ n, err := Run(ctx, cfg)
if err != nil {
t.Fatalf("Run: %v", err)
}
@@ -57,7 +60,7 @@ func TestRun_unsupportedExt(t *testing.T) {
t.Fatal(err)
}
- _, err := Run(&config.Config{InputDir: in, OutputDir: out, BaseURL: "https://x"})
+ _, err := Run(ctx, &config.Config{InputDir: in, OutputDir: out, BaseURL: "https://x"})
if err == nil {
t.Fatal("expected error")
}
@@ -66,7 +69,7 @@ func TestRun_unsupportedExt(t *testing.T) {
func TestRun_readInputDirFails(t *testing.T) {
t.Parallel()
- _, err := Run(&config.Config{InputDir: "/nonexistent/inbox/xyz", OutputDir: t.TempDir(), BaseURL: "https://x"})
+ _, err := Run(ctx, &config.Config{InputDir: "/nonexistent/inbox/xyz", OutputDir: t.TempDir(), BaseURL: "https://x"})
if err == nil {
t.Fatal("expected error")
}
@@ -89,7 +92,7 @@ func TestRun_png(t *testing.T) {
}
f.Close()
- n, err := Run(&config.Config{InputDir: in, OutputDir: out, BaseURL: "https://x"})
+ n, err := Run(ctx, &config.Config{InputDir: in, OutputDir: out, BaseURL: "https://x"})
if err != nil {
t.Fatalf("Run: %v", err)
}
@@ -107,7 +110,7 @@ func TestRun_mp3(t *testing.T) {
t.Fatal(err)
}
- n, err := Run(&config.Config{InputDir: in, OutputDir: out, BaseURL: "https://x"})
+ n, err := Run(ctx, &config.Config{InputDir: in, OutputDir: out, BaseURL: "https://x"})
if err != nil {
t.Fatalf("Run: %v", err)
}
@@ -125,7 +128,7 @@ func TestRun_markdown(t *testing.T) {
t.Fatal(err)
}
- n, err := Run(&config.Config{InputDir: in, OutputDir: out, BaseURL: "https://x"})
+ n, err := Run(ctx, &config.Config{InputDir: in, OutputDir: out, BaseURL: "https://x"})
if err != nil {
t.Fatalf("Run: %v", err)
}
@@ -216,7 +219,7 @@ text`
t.Fatal(err)
}
- n, err := Run(&config.Config{InputDir: in, OutputDir: out, BaseURL: "https://x"})
+ n, err := Run(ctx, &config.Config{InputDir: in, OutputDir: out, BaseURL: "https://x"})
if err != nil {
t.Fatalf("Run: %v", err)
}
@@ -264,7 +267,7 @@ func TestRun_twoMarkdownsClaimingSameImageFails(t *testing.T) {
t.Fatal(err)
}
- _, err = Run(&config.Config{InputDir: in, OutputDir: out, BaseURL: "https://x"})
+ _, err = Run(ctx, &config.Config{InputDir: in, OutputDir: out, BaseURL: "https://x"})
if err == nil {
t.Fatal("expected error when two markdowns claim the same image")
}
@@ -307,7 +310,7 @@ func TestRun_duplicateImageClaimsInSameMarkdownAllowed(t *testing.T) {
t.Fatal(err)
}
- n, err := Run(&config.Config{InputDir: in, OutputDir: out, BaseURL: "https://x"})
+ n, err := Run(ctx, &config.Config{InputDir: in, OutputDir: out, BaseURL: "https://x"})
if err != nil {
t.Fatalf("Run: %v", err)
}