aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-07-23 15:13:17 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-07-23 15:13:17 +0200
commita600efbe3f3d2c29e3aa6e7877937a29429b9a1e (patch)
treee471123b0356e40af246842eff9223e7bb935464
parentaa6c9dad764089a3f0e287b5f90937e97dde3921 (diff)
downloadlectio-a600efbe3f3d2c29e3aa6e7877937a29429b9a1e.tar.gz
lectio-a600efbe3f3d2c29e3aa6e7877937a29429b9a1e.zip
web: default lectio-web to port 1099 with free-port fallback
chooseListener(port) factors the listen logic out of Run: port==0 now tries defaultWebPort (1099) first and only falls back to an OS-picked free port if 1099 is taken; a non-zero port is still bound exactly, surfacing its bind error as before. config.Default() keeps WebPort: 0 unchanged (0 still means "auto"). Adds TestChooseListener, robust to sandboxes that can't bind 1099 or :0 (t.Skip instead of failing). Also folds the ?ref= whitespace trim into renderLookup itself so GET / and GET /lookup treat a whitespace-only ref identically (previously only lookupHandler trimmed it).
-rw-r--r--internal/config/config.toml2
-rw-r--r--internal/web/server.go33
-rw-r--r--internal/web/server_test.go58
3 files changed, 85 insertions, 8 deletions
diff --git a/internal/config/config.toml b/internal/config/config.toml
index 9c379e7..cfcb85b 100644
--- a/internal/config/config.toml
+++ b/internal/config/config.toml
@@ -7,7 +7,7 @@ width = 0 # CLI wrap width; 0 = detect terminal
all = false # default to all parts (true) or just the gospel (false)
offline = false # true = never fetch; read only harvested sigla + cache
web_theme = "transfiguration" # built-in order/season theme or a user theme in ~/.config/lectio/themes/
-web_port = 0 # lectio-web port; 0 = auto-pick a free port
+web_port = 0 # lectio-web port; 0 = try 1099, then any free port
web_display = "horizontal" # lectio-web layout: "horizontal" (stacked), "vertical" (columns), "interlinear" (verse-by-verse)
# Which parts to show. Both tables are commented out -> every part is shown.
diff --git a/internal/web/server.go b/internal/web/server.go
index d484b69..5886f0f 100644
--- a/internal/web/server.go
+++ b/internal/web/server.go
@@ -270,9 +270,12 @@ type lookupColumn struct {
// applies to a section's own citation via render.GatherVersion, not a
// free-typed lookup) against each requested version. Shared by lookupHandler
// (the HTMX partial) and indexHandler (so a bookmarked/shared "/?ref=..."
-// link shows the same result instead of an empty pane). An empty ref
-// renders nothing, matching lookupHandler's previous no-op behavior.
+// link shows the same result instead of an empty pane). ref is trimmed of
+// surrounding whitespace here so both routes treat a whitespace-only ref
+// identically; an empty (or now-empty) ref renders nothing, matching
+// lookupHandler's previous no-op behavior.
func renderLookup(ref string, versions []string) template.HTML {
+ ref = strings.TrimSpace(ref)
if ref == "" {
return ""
}
@@ -293,7 +296,7 @@ func renderLookup(ref string, versions []string) template.HTML {
// its doc comment for the lookup semantics).
func lookupHandler(cfg config.Config) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
- ref := strings.TrimSpace(r.URL.Query().Get("ref"))
+ ref := r.URL.Query().Get("ref") // renderLookup trims whitespace
w.Header().Set("Content-Type", "text/html; charset=utf-8")
io.WriteString(w, string(renderLookup(ref, requestVersions(cfg, r))))
}
@@ -322,11 +325,27 @@ func themeCSSHandler(cfg config.Config) http.HandlerFunc {
}
}
-// Run starts lectio-web: listens on cfg.WebPort (0 picks a free OS port),
-// prints the URL, best-effort opens it in a browser, and serves until the
-// listener errors.
+// defaultWebPort is the port chooseListener prefers when cfg.WebPort is 0.
+const defaultWebPort = 1099
+
+// chooseListener binds the port to serve on. port==0 means "prefer
+// defaultWebPort (1099), else let the OS pick a free port"; a non-zero port
+// is bound exactly (and its bind error surfaced if the port is in use).
+func chooseListener(port int) (net.Listener, error) {
+ if port != 0 {
+ return net.Listen("tcp", fmt.Sprintf(":%d", port))
+ }
+ if ln, err := net.Listen("tcp", fmt.Sprintf(":%d", defaultWebPort)); err == nil {
+ return ln, nil
+ }
+ return net.Listen("tcp", ":0") // 1099 taken -> any free port
+}
+
+// Run starts lectio-web: listens on cfg.WebPort via chooseListener (0
+// prefers 1099, falling back to a free OS port), prints the URL, best-effort
+// opens it in a browser, and serves until the listener errors.
func Run(cfg config.Config) error {
- ln, err := net.Listen("tcp", fmt.Sprintf(":%d", cfg.WebPort))
+ ln, err := chooseListener(cfg.WebPort)
if err != nil {
return err
}
diff --git a/internal/web/server_test.go b/internal/web/server_test.go
index b5cf78a..0268d3b 100644
--- a/internal/web/server_test.go
+++ b/internal/web/server_test.go
@@ -1,6 +1,7 @@
package web
import (
+ "net"
"net/http"
"net/http/httptest"
"os"
@@ -196,3 +197,60 @@ func TestServer(t *testing.T) {
}
})
}
+
+// TestChooseListener exercises chooseListener's port-selection logic
+// directly (no HTTP serving): port==0 prefers defaultWebPort (1099) and
+// falls back to a free OS port when 1099 is taken, and a non-zero port is
+// bound exactly. Sandboxed/CI environments may not permit binding 1099 (or
+// may race another process for it), so those assertions t.Skip rather than
+// fail the suite.
+func TestChooseListener(t *testing.T) {
+ t.Run("zero port returns a listener with a non-zero port", func(t *testing.T) {
+ ln, err := chooseListener(0)
+ if err != nil {
+ t.Fatalf("chooseListener(0) error: %v", err)
+ }
+ defer ln.Close()
+ port := ln.Addr().(*net.TCPAddr).Port
+ if port == 0 {
+ t.Errorf("chooseListener(0) returned port 0, want non-zero")
+ }
+ })
+
+ t.Run("falls back to a free port when 1099 is taken", func(t *testing.T) {
+ pre, err := net.Listen("tcp", ":1099")
+ if err != nil {
+ t.Skipf("cannot bind :1099 in this environment, skipping fallback assertion: %v", err)
+ }
+ defer pre.Close()
+
+ ln, err := chooseListener(0)
+ if err != nil {
+ t.Fatalf("chooseListener(0) error while 1099 is taken: %v", err)
+ }
+ defer ln.Close()
+ port := ln.Addr().(*net.TCPAddr).Port
+ if port == 1099 {
+ t.Errorf("chooseListener(0) returned 1099 even though it was already taken")
+ }
+ })
+
+ t.Run("explicit non-zero port is bound exactly", func(t *testing.T) {
+ probe, err := net.Listen("tcp", ":0")
+ if err != nil {
+ t.Skipf("cannot bind :0 to pick a free port in this environment: %v", err)
+ }
+ want := probe.Addr().(*net.TCPAddr).Port
+ probe.Close()
+
+ ln, err := chooseListener(want)
+ if err != nil {
+ t.Skipf("could not bind explicit port %d (likely a race with another process): %v", want, err)
+ }
+ defer ln.Close()
+ got := ln.Addr().(*net.TCPAddr).Port
+ if got != want {
+ t.Errorf("chooseListener(%d) bound port %d, want exactly %d", want, got, want)
+ }
+ })
+}