refactor(tools): bin/{subject}/{method} layout; Go serve+watch modules

Move serve/ (module) -> bin/server, tools/ -> bin/tools, replace bin/kb-watch
bash with bin/watch Go package; self-executing Go shebangs bin/serve.go and
bin/kb/watch.go; Docker + CI + git/import + docs repointed. Multi-stage image
builds static serve+watch binaries (no Go runtime in container).
This commit is contained in:
2026-08-11 09:52:20 +01:00
parent d6b17e8819
commit 8781c0c3eb
45 changed files with 709 additions and 91 deletions
+154
View File
@@ -0,0 +1,154 @@
// Package server serves the 2dph brain over HTTP.
//
// Async by design: every request runs on its own goroutine, and CPU-heavy
// searches are serialized through a bounded worker pool (a counting
// semaphore) so N requests can't spawn N Python interpreters at once.
//
// Used by bin/serve.go which is a self-executing shebang script:
//
// ///usr/bin/env go run "$0" "$@"; exit
// package main
// import "github.com/eSlider/2dph/bin/server"
// func main() { server.Run() }
package server
import (
"context"
"encoding/json"
"errors"
"log"
"net/http"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
)
type Searcher interface {
Search(ctx context.Context, query string, limit int) ([]byte, error)
}
type Server struct {
searcher Searcher
semaphore chan struct{}
}
const defaultPort = 8630
func NewServer(searcher Searcher, workers int) http.Handler {
return &Server{
searcher: searcher,
semaphore: make(chan struct{}, workers),
}
}
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/health":
writeJSON(w, http.StatusOK, map[string]any{"status": "ok"})
case r.URL.Path == "/search":
s.handleSearch(w, r)
default:
writeJSON(w, http.StatusNotFound, map[string]any{"error": "not found"})
}
}
func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
q := strings.TrimSpace(r.URL.Query().Get("q"))
if q == "" {
writeJSON(w, http.StatusBadRequest, map[string]any{"error": "q required"})
return
}
limit := 10
if raw := r.URL.Query().Get("n"); raw != "" {
n, err := strconv.Atoi(raw)
if err != nil || n < 1 || n > 100 {
writeJSON(w, http.StatusBadRequest, map[string]any{"error": "n must be int 1..100"})
return
}
limit = n
}
// Worker pool: block until a slot frees, so burst concurrency still
// bounds memory (no unbounded python processes).
select {
case s.semaphore <- struct{}{}:
defer func() { <-s.semaphore }()
case <-r.Context().Done():
return
}
body, err := s.searcher.Search(r.Context(), q, limit)
if err != nil {
writeJSON(w, http.StatusGatewayTimeout, map[string]any{"error": err.Error()})
return
}
writeRaw(w, http.StatusOK, body)
}
func writeJSON(w http.ResponseWriter, code int, obj any) {
body, _ := json.Marshal(obj)
writeRaw(w, code, body)
}
func writeRaw(w http.ResponseWriter, code int, body []byte) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Content-Length", strconv.Itoa(len(body)))
w.WriteHeader(code)
w.Write(body)
}
// brainSearcher shells out to bin/kb/search --json. A single python search
// is bounded and short-lived; the worker pool keeps at most N live.
type brainSearcher struct {
cmdPath string
timeout time.Duration
}
func (b *brainSearcher) Search(ctx context.Context, query string, limit int) ([]byte, error) {
ctx, cancel := context.WithTimeout(ctx, b.timeout)
defer cancel()
cmd := exec.CommandContext(ctx, b.cmdPath, "--json", "-n", strconv.Itoa(limit), query)
out, err := cmd.Output()
if err != nil {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
return nil, errors.New("search backend failed: " + strings.TrimSpace(string(exitErr.Stderr)))
}
return nil, err
}
return out, nil
}
// Run starts the HTTP server. Reads env: KB_SEARCH_CMD (default bin/kb/search,
// relative to the repo root given by KB_ROOT), KB_WORKERS (default 4), KB_PORT
// (default 8630).
func Run() {
root := os.Getenv("KB_ROOT")
searchPath := os.Getenv("KB_SEARCH_CMD")
if searchPath == "" {
searchPath = filepath.Join(root, "bin", "kb", "search")
}
workers := 4
if raw := os.Getenv("KB_WORKERS"); raw != "" {
if n, err := strconv.Atoi(raw); err == nil && n > 0 {
workers = n
}
}
port := defaultPort
if raw := os.Getenv("KB_PORT"); raw != "" {
if n, err := strconv.Atoi(raw); err == nil && n > 0 {
port = n
}
}
searcher := &brainSearcher{cmdPath: searchPath, timeout: 60 * time.Second}
handler := NewServer(searcher, workers)
addr := "127.0.0.1:" + strconv.Itoa(port)
log.Printf("serve: %s (workers=%d)", addr, workers)
if err := http.ListenAndServe(addr, handler); err != nil {
log.Fatal(err)
}
}
+167
View File
@@ -0,0 +1,167 @@
package server
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"sync"
"sync/atomic"
"testing"
"time"
)
// fakeSearcher is an injectable Searcher for tests (no python involved).
type fakeSearcher struct {
mu sync.Mutex
delay time.Duration
calls int
active atomic.Int32
maxSeen atomic.Int32
callback func(q string, limit int) ([]byte, error)
}
func (f *fakeSearcher) Search(ctx context.Context, query string, limit int) ([]byte, error) {
f.mu.Lock()
f.calls++
f.mu.Unlock()
n := f.active.Add(1)
for {
old := f.maxSeen.Load()
if n <= old || f.maxSeen.CompareAndSwap(old, n) {
break
}
}
defer f.active.Add(-1)
if f.delay > 0 {
select {
case <-time.After(f.delay):
case <-ctx.Done():
return nil, ctx.Err()
}
}
if f.callback != nil {
return f.callback(query, limit)
}
return []byte(`{"query":"` + query + `","count":0,"results":[]}`), nil
}
func (f *fakeSearcher) count() int {
f.mu.Lock()
defer f.mu.Unlock()
return f.calls
}
func get(t *testing.T, h http.Handler, path string) (int, []byte) {
t.Helper()
req := httptest.NewRequest(http.MethodGet, path, nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec.Code, rec.Body.Bytes()
}
func TestHealth(t *testing.T) {
h := NewServer(&fakeSearcher{}, 1)
code, body := get(t, h, "/health")
if code != http.StatusOK {
t.Fatalf("health code = %d, want 200", code)
}
var out map[string]any
if err := json.Unmarshal(body, &out); err != nil {
t.Fatalf("health body not json: %v (%s)", err, body)
}
if out["status"] != "ok" {
t.Fatalf("health status = %v, want ok", out["status"])
}
}
func TestSearchMissingQuery(t *testing.T) {
h := NewServer(&fakeSearcher{}, 1)
if code, _ := get(t, h, "/search"); code != http.StatusBadRequest {
t.Fatalf("code = %d, want 400", code)
}
}
func TestSearchReturnsSearcherResult(t *testing.T) {
fs := &fakeSearcher{callback: func(q string, limit int) ([]byte, error) {
return []byte(`{"query":"` + q + `","count":1,"results":[{"id":"x"}]}`), nil
}}
h := NewServer(fs, 1)
code, body := get(t, h, "/search?q=matrix")
if code != http.StatusOK {
t.Fatalf("code = %d, want 200", code)
}
var out map[string]any
if err := json.Unmarshal(body, &out); err != nil {
t.Fatalf("body not json: %v (%s)", err, body)
}
if out["query"] != "matrix" {
t.Fatalf("query = %v, want matrix", out["query"])
}
if fs.count() != 1 {
t.Fatalf("searcher called %d times, want 1", fs.count())
}
}
func TestSearchConcurrencyBounded(t *testing.T) {
// 8 parallel requests on a 3-worker pool: at most 3 concurrent searches.
fs := &fakeSearcher{delay: 20 * time.Millisecond}
h := NewServer(fs, 3)
var wg sync.WaitGroup
for i := 0; i < 8; i++ {
wg.Add(1)
go func() {
defer wg.Done()
req := httptest.NewRequest(http.MethodGet, "/search?q=abc", nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("code = %d, want 200", rec.Code)
}
}()
}
wg.Wait()
if calls := fs.count(); calls != 8 {
t.Fatalf("searcher called %d times, want 8", calls)
}
if max := fs.maxSeen.Load(); max > 3 {
t.Fatalf("max concurrent = %d, want <= 3", max)
}
if max := fs.maxSeen.Load(); max < 1 {
t.Fatalf("max concurrent = %d, want >= 1", max)
}
}
func TestSearchRejectsBadLimit(t *testing.T) {
h := NewServer(&fakeSearcher{}, 1)
if code, _ := get(t, h, "/search?q=x&n=hundred"); code != http.StatusBadRequest {
t.Fatalf("code = %d, want 400", code)
}
}
func TestSearchTimeout(t *testing.T) {
fs := &fakeSearcher{delay: time.Second}
h := NewServer(fs, 1)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond)
defer cancel()
req := httptest.NewRequest(http.MethodGet, "/search?q=slow", nil).WithContext(ctx)
rec := httptest.NewRecorder()
done := make(chan struct{})
go func() {
h.ServeHTTP(rec, req)
close(done)
}()
select {
case <-done:
// ServeHTTP returned; body should be an error json (we don't require a
// specific code for the pathological ctx-cancel timing, only that it
// does not hang forever).
return
case <-time.After(500 * time.Millisecond):
t.Fatal("request hung after context cancellation")
}
}