mesh/merge-gate head 21d038d3397c merged into trunk 5d70eb3e35a9 — pass, with a note: builds mesh-tools, node-tools (+39 dependent(s)) → ace, g14, novox, s…
mesh/repo-check head 21d038d3397c merged into trunk 5d70eb3e35a9 — pass: THE CHANGE ALTERS ITS OWN CHECK (merge-check.sh): main's version judged it; the ch…
mesh/delivery delivered
Closing the console shut its server down with a context that never ends, so a stop or restart of the tool runner waited on any mesh_wait in flight for up to ten minutes. The server's base context is now cancelled first, ending every wait at once, with a five-second shutdown as a backstop (second review of issue 745). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
145 lines
4.7 KiB
Go
145 lines
4.7 KiB
Go
package console
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/novox/mesh-tools/node-tools/internal/wire"
|
|
)
|
|
|
|
// bodyLimit is the most a request body may be.
|
|
const bodyLimit = 1 << 20
|
|
|
|
var loopback = map[string]bool{"127.0.0.1": true, "::1": true, "localhost": true, "[::1]": true}
|
|
|
|
// Listening is a console that listens: where, with the port the machine gave, and how to stop it.
|
|
type Listening struct {
|
|
Address string
|
|
Close func() error
|
|
}
|
|
|
|
// Serve listens on host:port, refused unless the host is loopback — said before binding, so a
|
|
// console that would open to a network is a startup failure (ADR 0152).
|
|
func Serve(s *Surface, listen string) (*Listening, error) {
|
|
at := strings.LastIndex(listen, ":")
|
|
if at < 0 {
|
|
return nil, fmt.Errorf("%q is not host:port", listen)
|
|
}
|
|
host, portText := listen[:at], listen[at+1:]
|
|
if !loopback[host] {
|
|
return nil, fmt.Errorf(`the console listens on loopback and nowhere else (novox/hq ADR 0152): %q is not this `+
|
|
"machine's own address — whoever is on the machine owns the mesh there, and nobody else may reach this", host)
|
|
}
|
|
port, err := strconv.Atoi(portText)
|
|
if err != nil || port < 0 || port > 65535 {
|
|
return nil, fmt.Errorf("%q is not a port", portText)
|
|
}
|
|
ln, err := net.Listen("tcp", net.JoinHostPort(strings.Trim(host, "[]"), portText))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// Every request's context ends when the console is closed, so a mesh_wait in flight ends at once rather
|
|
// than holding a stop or a restart for up to its ten minutes (the review of issue 745).
|
|
base, cancel := context.WithCancel(context.Background())
|
|
server := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { route(w, r, s) }),
|
|
BaseContext: func(net.Listener) context.Context { return base }}
|
|
go func() { _ = server.Serve(ln) }()
|
|
bound := ln.Addr().(*net.TCPAddr).Port
|
|
return &Listening{Address: host + ":" + strconv.Itoa(bound), Close: func() error {
|
|
cancel()
|
|
// A backstop: what is still answering after the cancel is cut off, never waited on for long.
|
|
ctx, done := context.WithTimeout(context.Background(), ShutdownWithin)
|
|
defer done()
|
|
err := server.Shutdown(ctx)
|
|
if errors.Is(err, context.DeadlineExceeded) {
|
|
return server.Close()
|
|
}
|
|
return err
|
|
}}, nil
|
|
}
|
|
|
|
// ShutdownWithin is how long closing the console waits for the requests still being answered after their
|
|
// context was cancelled.
|
|
var ShutdownWithin = 5 * time.Second
|
|
|
|
func route(w http.ResponseWriter, r *http.Request, s *Surface) {
|
|
switch r.URL.Path {
|
|
case "/":
|
|
w.Header().Set("content-type", "text/plain; charset=utf-8")
|
|
_, _ = io.WriteString(w, "the mesh's console: MCP over HTTP at POST /mcp (novox/hq design 34)\n")
|
|
return
|
|
case "/mcp":
|
|
default:
|
|
writeJSON(w, 404, map[string]any{"error": "the console serves /mcp and nothing else"})
|
|
return
|
|
}
|
|
switch r.Method {
|
|
case http.MethodPost:
|
|
case http.MethodDelete:
|
|
w.WriteHeader(204) // no session to end
|
|
return
|
|
default:
|
|
w.Header().Set("allow", "POST, DELETE")
|
|
w.WriteHeader(405)
|
|
return
|
|
}
|
|
body, err := io.ReadAll(io.LimitReader(r.Body, bodyLimit+1))
|
|
if err != nil || len(body) > bodyLimit {
|
|
if err == nil {
|
|
err = fmt.Errorf("the request is larger than %d bytes", bodyLimit)
|
|
}
|
|
writeJSON(w, 413, map[string]any{"jsonrpc": "2.0", "id": nil, "error": map[string]any{"code": -32600, "message": err.Error()}})
|
|
return
|
|
}
|
|
trimmed := strings.TrimSpace(string(body))
|
|
if strings.HasPrefix(trimmed, "[") {
|
|
var batch []Request
|
|
if err := json.Unmarshal(body, &batch); err != nil {
|
|
writeJSON(w, 400, map[string]any{"jsonrpc": "2.0", "id": nil, "error": map[string]any{"code": -32700, "message": "the body is not JSON"}})
|
|
return
|
|
}
|
|
replies := []*Reply{}
|
|
for _, req := range batch {
|
|
if reply := s.HandleContext(r.Context(), req); reply != nil {
|
|
replies = append(replies, reply)
|
|
}
|
|
}
|
|
if len(replies) == 0 {
|
|
w.WriteHeader(202)
|
|
return
|
|
}
|
|
writeJSON(w, 200, replies)
|
|
return
|
|
}
|
|
var req Request
|
|
if err := json.Unmarshal(body, &req); err != nil {
|
|
writeJSON(w, 400, map[string]any{"jsonrpc": "2.0", "id": nil, "error": map[string]any{"code": -32700, "message": "the body is not JSON"}})
|
|
return
|
|
}
|
|
reply := s.HandleContext(r.Context(), req)
|
|
if reply == nil {
|
|
w.WriteHeader(202)
|
|
return
|
|
}
|
|
writeJSON(w, 200, reply)
|
|
}
|
|
|
|
func writeJSON(w http.ResponseWriter, status int, body any) {
|
|
text, err := wire.Marshal(body)
|
|
if err != nil {
|
|
text, _ = json.Marshal(map[string]any{"error": errors.New("unencodable answer").Error()})
|
|
}
|
|
w.Header().Set("content-type", "application/json; charset=utf-8")
|
|
w.Header().Set("content-length", strconv.Itoa(len(text)))
|
|
w.WriteHeader(status)
|
|
_, _ = w.Write(text)
|
|
}
|