From 461af2c40097059242afa2651257ee859c2001c3 Mon Sep 17 00:00:00 2001 From: younsl Date: Fri, 18 Sep 2026 00:15:53 +0900 Subject: [PATCH 1/2] fix: release MCP session state so the server stops leaking heap The streamable HTTP server registers a session on `initialize` and, on mcp-go v0.43.2, never releases it: `handleDelete` clears the per-session stores but never calls `UnregisterSession`, so every session a client ever opened is retained. A POST-only client leaks roughly 8 KB per initialize, which in production shows up as linear heap growth until the container is OOMKilled. Bump mcp-go to v1.1.0, which unregisters the session on DELETE, and enable its idle sweeper so sessions abandoned without a DELETE are reclaimed too. The sweeper is opt-in even on v1.1.0, so the bump alone is not enough. The new `--session-idle-ttl` flag defaults to 10m and 0 disables it. Shutdown now stops the sweeper and closes sessions that are still registered. Measured over repeated bursts of 500 initialize requests, 15s apart: mcp-go v0.43.2 3.44 -> 7.74 -> 12.96 -> 17.96 -> 21.65 MB live heap this change 3.93 -> 9.55 -> 9.63 -> 9.55 -> 9.55 MB live heap Closes #84 Signed-off-by: younsl --- README.md | 1 + cmd/main.go | 45 ++++++++--- cmd/streamable_http_test.go | 151 ++++++++++++++++++++++++++++++++++++ go.mod | 9 +-- go.sum | 18 ++--- 5 files changed, 196 insertions(+), 28 deletions(-) create mode 100644 cmd/streamable_http_test.go diff --git a/README.md b/README.md index 4b1c518..e06067e 100644 --- a/README.md +++ b/README.md @@ -197,6 +197,7 @@ The server runs using sse transport for MCP communication. | `--stdio` | `false` | Use stdio for communication instead of HTTP | | `--tools` | `[]` (all) | Comma-separated list of tool providers to register | | `--read-only` | `false` | Disable tools that perform write operations | +| `--session-idle-ttl` | `10m` | Reclaim streamable HTTP session state after this idle duration (`0` disables the sweeper) | | `--kubeconfig` | `""` | Path to kubeconfig file (defaults to in-cluster config) | | `--version`, `-v` | `false` | Show version information and exit | diff --git a/cmd/main.go b/cmd/main.go index 943b7db..af0d4c1 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -46,6 +46,8 @@ var ( showVersion bool readOnly bool + sessionIdleTTL time.Duration + // These variables should be set during build time using -ldflags Name = "kagent-tools-server" Version = version.Version @@ -66,6 +68,7 @@ func init() { rootCmd.Flags().StringSliceVar(&tools, "tools", []string{}, "List of tools to register. If empty, all tools are registered.") rootCmd.Flags().BoolVarP(&showVersion, "version", "v", false, "Show version information and exit") rootCmd.Flags().BoolVar(&readOnly, "read-only", false, "Run in read-only mode (disable tools that perform write operations)") + rootCmd.Flags().DurationVar(&sessionIdleTTL, "session-idle-ttl", 10*time.Minute, "Reclaim streamable HTTP session state after this idle duration (0 disables the sweeper)") kubeconfig = rootCmd.Flags().String("kubeconfig", "", "kubeconfig file path (optional, defaults to in-cluster config)") // if found .env file, load it @@ -160,7 +163,8 @@ func run(cmd *cobra.Command, args []string) { // HTTP server reference (only used when not in stdio mode) var httpServer *http.Server - var metricsServer *http.Server // Separate server for metrics if metricsPort is different from main port + var metricsServer *http.Server // Separate server for metrics if metricsPort is different from main port + var streamableServer *server.StreamableHTTPServer // Streamable HTTP transport, shut down to stop its session sweeper // Start server based on chosen mode wg.Add(1) @@ -170,9 +174,7 @@ func run(cmd *cobra.Command, args []string) { runStdioServer(ctx, mcp) }() } else { - sseServer := server.NewStreamableHTTPServer(mcp, - server.WithHeartbeatInterval(30*time.Second), - ) + streamableServer = newStreamableHTTPServer(mcp, sessionIdleTTL) // Create a mux to handle different routes mux := http.NewServeMux() @@ -198,9 +200,7 @@ func run(cmd *cobra.Command, args []string) { Handler: metricsMux, } - wg.Add(1) - go func() { - defer wg.Done() + wg.Go(func() { logger.Get().Info("Starting Prometheus metrics endpoint on /metrics", "port", strconv.Itoa(metricsPort)) if err := metricsServer.ListenAndServe(); err != nil { if !errors.Is(err, http.ErrServerClosed) { @@ -209,7 +209,7 @@ func run(cmd *cobra.Command, args []string) { logger.Get().Info("Metrics server closed gracefully.") } } - }() + }) } else { logger.Get().Info("Starting Prometheus metrics endpoint on /metrics", "port", strconv.Itoa(port)) mux.Handle("/metrics", promhttp.HandlerFor(registry, promhttp.HandlerOpts{})) @@ -222,7 +222,7 @@ func run(cmd *cobra.Command, args []string) { // Handle all other routes with the MCP server wrapped in telemetry middleware mux.Handle("/", telemetry.HTTPMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - sseServer.ServeHTTP(w, r) + streamableServer.ServeHTTP(w, r) }))) httpServer = &http.Server{ @@ -232,7 +232,7 @@ func run(cmd *cobra.Command, args []string) { go func() { defer wg.Done() - logger.Get().Info("Running KAgent Tools Server", "port", fmt.Sprintf(":%d", port), "tools", strings.Join(tools, ",")) + logger.Get().Info("Running KAgent Tools Server", "port", fmt.Sprintf(":%d", port), "tools", strings.Join(tools, ","), "session_idle_ttl", sessionIdleTTL.String()) if err := httpServer.ListenAndServe(); err != nil { if !errors.Is(err, http.ErrServerClosed) { logger.Get().Error("Failed to start HTTP server", "error", err) @@ -268,6 +268,18 @@ func run(cmd *cobra.Command, args []string) { } } + // Stop the session sweeper and close sessions still registered with the + // MCP server, so nothing outlives the transport it belonged to + if !stdio && streamableServer != nil { + shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer shutdownCancel() + + if err := streamableServer.Shutdown(shutdownCtx); err != nil { + logger.Get().Error("Failed to shutdown MCP streamable HTTP server gracefully", "error", err) + rootSpan.RecordError(err) + } + } + // Gracefully shutdown metrics server if running separately if !stdio && metricsServer != nil { shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second) @@ -287,6 +299,19 @@ func run(cmd *cobra.Command, args []string) { logger.Get().Info("Server shutdown complete") } +// newStreamableHTTPServer builds the streamable HTTP transport. +// +// A session is registered on `initialize` and released only when the client +// sends DELETE, so a client that goes away without one leaves its session +// behind forever. idleTTL bounds that by reclaiming sessions that have seen no +// traffic for that long; zero or less disables the sweeper. +func newStreamableHTTPServer(mcpServer *server.MCPServer, idleTTL time.Duration) *server.StreamableHTTPServer { + return server.NewStreamableHTTPServer(mcpServer, + server.WithHeartbeatInterval(30*time.Second), + server.WithSessionIdleTTL(idleTTL), + ) +} + // writeResponse writes data to an HTTP response writer with proper error handling func writeResponse(w http.ResponseWriter, data []byte) error { _, err := w.Write(data) diff --git a/cmd/streamable_http_test.go b/cmd/streamable_http_test.go new file mode 100644 index 0000000..dbf4b0e --- /dev/null +++ b/cmd/streamable_http_test.go @@ -0,0 +1,151 @@ +package main + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/mark3labs/mcp-go/server" +) + +const initializeRequest = `{"jsonrpc":"2.0","id":1,"method":"initialize",` + + `"params":{"protocolVersion":"2025-03-26","capabilities":{},` + + `"clientInfo":{"name":"test","version":"1"}}}` + +// sessionRecorder counts the sessions the MCP server registers and releases. +// A session that is registered but never unregistered is retained state, which +// is what leaks when a client never sends DELETE. +type sessionRecorder struct { + mu sync.Mutex + registered int + unregistered int +} + +func (r *sessionRecorder) hooks() *server.Hooks { + hooks := &server.Hooks{} + hooks.AddOnRegisterSession(func(_ context.Context, _ server.ClientSession) { + r.mu.Lock() + defer r.mu.Unlock() + r.registered++ + }) + hooks.AddOnUnregisterSession(func(_ context.Context, _ server.ClientSession) { + r.mu.Lock() + defer r.mu.Unlock() + r.unregistered++ + }) + return hooks +} + +func (r *sessionRecorder) counts() (int, int) { + r.mu.Lock() + defer r.mu.Unlock() + return r.registered, r.unregistered +} + +// newRecordedServer starts the streamable HTTP transport wired exactly as the +// server wires it, with session hooks attached. +func newRecordedServer(t *testing.T, idleTTL time.Duration) (*httptest.Server, *sessionRecorder) { + t.Helper() + + recorder := &sessionRecorder{} + mcpServer := server.NewMCPServer("test-server", "test", server.WithHooks(recorder.hooks())) + streamableServer := newStreamableHTTPServer(mcpServer, idleTTL) + t.Cleanup(func() { + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = streamableServer.Shutdown(shutdownCtx) + }) + + httpServer := httptest.NewServer(streamableServer) + t.Cleanup(httpServer.Close) + return httpServer, recorder +} + +// initializeSession sends an initialize request and returns the session ID the +// server assigned to it. +func initializeSession(t *testing.T, httpServer *httptest.Server) string { + t.Helper() + + req, err := http.NewRequest(http.MethodPost, httpServer.URL, strings.NewReader(initializeRequest)) + if err != nil { + t.Fatalf("build initialize request: %v", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json, text/event-stream") + + resp, err := httpServer.Client().Do(req) + if err != nil { + t.Fatalf("send initialize request: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("initialize returned status %d, want %d", resp.StatusCode, http.StatusOK) + } + sessionID := resp.Header.Get("Mcp-Session-Id") + if sessionID == "" { + t.Fatal("initialize response carried no Mcp-Session-Id header") + } + return sessionID +} + +// waitForUnregistered polls until the expected number of sessions has been +// released, so the sweeper's own tick interval does not make the test flaky. +func waitForUnregistered(t *testing.T, recorder *sessionRecorder, want int, timeout time.Duration) { + t.Helper() + + deadline := time.Now().Add(timeout) + for { + _, unregistered := recorder.counts() + if unregistered >= want { + return + } + if time.Now().After(deadline) { + t.Fatalf("released %d sessions after %s, want %d", unregistered, timeout, want) + } + time.Sleep(20 * time.Millisecond) + } +} + +// A client that ends its session explicitly must have it released. +func TestStreamableHTTPServerReleasesSessionOnDelete(t *testing.T) { + httpServer, recorder := newRecordedServer(t, 0) + sessionID := initializeSession(t, httpServer) + + if registered, _ := recorder.counts(); registered != 1 { + t.Fatalf("registered %d sessions, want 1", registered) + } + + req, err := http.NewRequest(http.MethodDelete, httpServer.URL, nil) + if err != nil { + t.Fatalf("build delete request: %v", err) + } + req.Header.Set("Mcp-Session-Id", sessionID) + + resp, err := httpServer.Client().Do(req) + if err != nil { + t.Fatalf("send delete request: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("delete returned status %d, want %d", resp.StatusCode, http.StatusOK) + } + waitForUnregistered(t, recorder, 1, 2*time.Second) +} + +// A client that goes away without a DELETE must not retain its session: the +// idle sweeper is what bounds memory for POST-only clients. +func TestStreamableHTTPServerSweepsIdleSession(t *testing.T) { + httpServer, recorder := newRecordedServer(t, 100*time.Millisecond) + initializeSession(t, httpServer) + + if registered, _ := recorder.counts(); registered != 1 { + t.Fatalf("registered %d sessions, want 1", registered) + } + waitForUnregistered(t, recorder, 1, 10*time.Second) +} diff --git a/go.mod b/go.mod index 7535dbd..cff640d 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ require ( github.com/joho/godotenv v1.5.1 github.com/kubescape/k8s-interface v0.0.203 github.com/kubescape/storage v0.0.239 - github.com/mark3labs/mcp-go v0.43.2 + github.com/mark3labs/mcp-go v1.1.0 github.com/onsi/ginkgo/v2 v2.27.2 github.com/onsi/gomega v1.38.2 github.com/prometheus/client_golang v1.23.2 @@ -38,13 +38,11 @@ require ( github.com/armosec/gojay v1.2.17 // indirect github.com/armosec/utils-go v0.0.58 // indirect github.com/armosec/utils-k8s-go v0.0.35 // indirect - github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/becheran/wildmatch-go v1.0.0 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/blang/semver/v4 v4.0.0 // indirect github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect github.com/briandowns/spinner v1.23.2 // indirect - github.com/buger/jsonparser v1.1.1 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect @@ -99,6 +97,7 @@ require ( github.com/google/gnostic-models v0.7.1 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/go-containerregistry v0.20.6 // indirect + github.com/google/jsonschema-go v0.4.2 // indirect github.com/google/licensecheck v0.3.1 // indirect github.com/google/pprof v0.0.0-20251114195745-4902fdda35c8 // indirect github.com/google/uuid v1.6.0 // indirect @@ -106,14 +105,12 @@ require ( github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/invopop/jsonschema v0.13.0 // indirect github.com/jinzhu/copier v0.4.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/compress v1.18.4 // indirect github.com/kubescape/go-logger v0.0.26 // indirect github.com/kylelemons/godebug v1.1.0 // indirect github.com/mackerelio/go-osstat v0.2.6 // indirect - github.com/mailru/easyjson v0.9.1 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect @@ -135,6 +132,7 @@ require ( github.com/prometheus/common v0.67.5 // indirect github.com/prometheus/procfs v0.19.2 // indirect github.com/sagikazarmark/locafero v0.12.0 // indirect + github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect github.com/sasha-s/go-deadlock v0.3.6 // indirect github.com/scylladb/go-set v1.0.3-0.20200225121959-cc7b2070d91e // indirect github.com/seccomp/libseccomp-golang v0.10.0 // indirect @@ -155,7 +153,6 @@ require ( github.com/vishvananda/netns v0.0.5 // indirect github.com/wagoodman/go-partybus v0.0.0-20230516145632-8ccac152c651 // indirect github.com/wagoodman/go-progress v0.0.0-20230925121702-07e42b3cdba0 // indirect - github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/yl2chen/cidranger v1.0.2 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect diff --git a/go.sum b/go.sum index 8e47384..70f120c 100644 --- a/go.sum +++ b/go.sum @@ -100,8 +100,6 @@ github.com/armosec/utils-go v0.0.58 h1:g9RnRkxZAmzTfPe2ruMo2OXSYLwVSegQSkSavOfma github.com/armosec/utils-go v0.0.58/go.mod h1:CdqKHKruVJMCxGcZXYW9J+5P9FZou8dMzVpcB0Xt8pk= github.com/armosec/utils-k8s-go v0.0.35 h1:CliNObhAca5UYl84m5OQecOTm9ZfMFI8648pYhQJiu4= github.com/armosec/utils-k8s-go v0.0.35/go.mod h1:iHwR/KhMFtdd8Px1oYexLZYOHqmdknfGTZ8b7sZS0Ms= -github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= -github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= github.com/becheran/wildmatch-go v1.0.0 h1:mE3dGGkTmpKtT4Z+88t8RStG40yN9T+kFEGj2PZFSzA= github.com/becheran/wildmatch-go v1.0.0/go.mod h1:gbMvj0NtVdJ15Mg/mH9uxk2R1QCistMyU7d9KFzroX4= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= @@ -117,8 +115,6 @@ github.com/bradfitz/go-smtpd v0.0.0-20170404230938-deb6d6237625/go.mod h1:HYsPBT github.com/briandowns/spinner v1.23.2 h1:Zc6ecUnI+YzLmJniCfDNaMbW0Wid1d5+qcTq4L2FW8w= github.com/briandowns/spinner v1.23.2/go.mod h1:LaZeM4wm2Ywy6vO571mvhQNRcWfRUnXOs0RcKV0wYKM= github.com/buger/jsonparser v0.0.0-20181115193947-bf1c66bbce23/go.mod h1:bbYlZJ7hK1yFx9hf58LP0zeX7UjIGs20ufpu3evjr+s= -github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= -github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= @@ -363,6 +359,8 @@ github.com/google/go-containerregistry v0.20.6/go.mod h1:T0x8MuoAoKX/873bkeSfLD2 github.com/google/go-github v17.0.0+incompatible/go.mod h1:zLgOLi98H3fifZn+44m+umXrS52loVEgC2AApnigrVQ= github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= +github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/licensecheck v0.3.1 h1:QoxgoDkaeC4nFrtGN1jV7IPmDCHFNIVh54e5hSt6sPs= github.com/google/licensecheck v0.3.1/go.mod h1:ORkR35t/JjW+emNKtfJDII0zlciG9JgbT7SmsohlHmY= github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= @@ -446,8 +444,6 @@ github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1: github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E= -github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= github.com/jellevandenhooff/dkim v0.0.0-20150330215556-f50fe3d243e1/go.mod h1:E0B/fFc00Y+Rasa88328GlI/XbtyysCtTHZS8h7IrBU= github.com/jinzhu/copier v0.4.0 h1:w3ciUoD19shMCRargcpm0cm91ytaBhDvuRpz1ODO/U8= github.com/jinzhu/copier v0.4.0/go.mod h1:DfbEm0FYsaqBcKcFuvmOZb218JkPGtvSHsKg8S8hyyg= @@ -493,10 +489,8 @@ github.com/mackerelio/go-osstat v0.2.6 h1:gs4U8BZeS1tjrL08tt5VUliVvSWP26Ai2Ob8Lr github.com/mackerelio/go-osstat v0.2.6/go.mod h1:lRy8V9ZuHpuRVZh+vyTkODeDPl3/d5MgXHtLSaqG8bA= github.com/magiconair/properties v1.8.5/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60= github.com/mailru/easyjson v0.0.0-20190312143242-1de009706dbe/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= -github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8= -github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= -github.com/mark3labs/mcp-go v0.43.2 h1:21PUSlWWiSbUPQwXIJ5WKlETixpFpq+WBpbMGDSVy/I= -github.com/mark3labs/mcp-go v0.43.2/go.mod h1:YnJfOL382MIWDx1kMY+2zsRHU/q78dBg9aFb8W6Thdw= +github.com/mark3labs/mcp-go v1.1.0 h1:9kZwJreq58QIaM+5h+k4JXbG0f0y0j9+DJCV5rGBkJA= +github.com/mark3labs/mcp-go v1.1.0/go.mod h1:r2fW4o3wsoJ7IMsx1Wuq5xeP8PRGXPDfNveoGAYbb/s= github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo= github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= @@ -623,6 +617,8 @@ github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb github.com/sagikazarmark/crypt v0.3.0/go.mod h1:uD/D+6UF4SrIR1uGEv7bBNkNqLGqUr43MRiaGWX1Nig= github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4= github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= github.com/sasha-s/go-deadlock v0.3.6 h1:TR7sfOnZ7x00tWPfD397Peodt57KzMDo+9Ae9rMiUmw= github.com/sasha-s/go-deadlock v0.3.6/go.mod h1:CUqNyyvMxTyjFqDT7MRg9mb4Dv/btmGTqSR+rky/UXo= github.com/scylladb/go-set v1.0.3-0.20200225121959-cc7b2070d91e h1:7q6NSFZDeGfvvtIRwBrU/aegEYJYmvev0cHAwo17zZQ= @@ -735,8 +731,6 @@ github.com/wagoodman/go-partybus v0.0.0-20230516145632-8ccac152c651 h1:jIVmlAFIq github.com/wagoodman/go-partybus v0.0.0-20230516145632-8ccac152c651/go.mod h1:b26F2tHLqaoRQf8DywqzVaV1MQ9yvjb0OMcNl7Nxu20= github.com/wagoodman/go-progress v0.0.0-20230925121702-07e42b3cdba0 h1:0KGbf+0SMg+UFy4e1A/CPVvXn21f1qtWdeJwxZFoQG8= github.com/wagoodman/go-progress v0.0.0-20230925121702-07e42b3cdba0/go.mod h1:jLXFoL31zFaHKAAyZUh+sxiTDFe1L1ZHrcK2T1itVKA= -github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= -github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= From 4ccbbe745268f0c6323e830ca1101a519a48ea05 Mon Sep 17 00:00:00 2001 From: younsl Date: Fri, 18 Sep 2026 00:26:02 +0900 Subject: [PATCH 2/2] test: run the cmd package tests in make test `make test` covered ./pkg/... and ./internal/... only, so the tests in cmd/ never ran in CI. That includes the session lifecycle regression tests this branch adds, which is the code path that leaked. Signed-off-by: younsl --- Makefile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index 2c7dd49..41fcee9 100644 --- a/Makefile +++ b/Makefile @@ -58,11 +58,11 @@ tidy: ## Run go mod tidy to ensure dependencies are up to date. .PHONY: test test: build lint ## Run all tests with build, lint, and coverage - go test -tags=test -v -cover ./pkg/... ./internal/... + go test -tags=test -v -cover ./cmd/... ./pkg/... ./internal/... .PHONY: test-only test-only: ## Run tests only (without build/lint for faster iteration) - go test -tags=test -v -cover ./pkg/... ./internal/... + go test -tags=test -v -cover ./cmd/... ./pkg/... ./internal/... .PHONY: e2e e2e: test retag