diff options
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/config/config.toml | 2 | ||||
| -rw-r--r-- | internal/web/server.go | 33 | ||||
| -rw-r--r-- | internal/web/server_test.go | 58 |
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) + } + }) +} |
