-
Notifications
You must be signed in to change notification settings - Fork 6
feat: Single Instance Proxy Architecture & Non-blocking Search Engine #28
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
4c8c143
feat: single-instance proxy mode — ensures only one heavy process runs
doITmagic 48f37ca
fix: SSE validation with json.Unmarshal + add 16 proxy tests
doITmagic 329cd63
fix(engine): do not block search during active indexing
doITmagic 14a6f7e
test(engine): fix TempDir cleanup race condition
doITmagic 61afa2f
test(proxy): fix golangci-lint errcheck warnings
doITmagic 620eb47
test(proxy): fix all PR review comments related to proxy architecture…
doITmagic 946fed7
fix(proxy): resolve remaining PR review comments - docstring, kill ch…
doITmagic 6a4d463
fix(proxy,engine): resolve new Copilot review comments - ragcode vali…
doITmagic File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,165 @@ | ||
| package proxy | ||
|
|
||
| import ( | ||
| "bytes" | ||
| "encoding/json" | ||
| "fmt" | ||
| "io" | ||
| "net" | ||
| "net/http" | ||
| "os/exec" | ||
| "runtime" | ||
| "strings" | ||
| "time" | ||
|
|
||
| "github.com/doITmagic/rag-code-mcp/internal/logger" | ||
| ) | ||
|
|
||
| // PortIsOccupied returns true if the given TCP port is already listening. | ||
| func PortIsOccupied(port int) bool { | ||
| conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", port), 1*time.Second) | ||
| if err != nil { | ||
| return false | ||
| } | ||
| conn.Close() | ||
| return true | ||
| } | ||
|
|
||
| // QueryMasterVersion sends an MCP initialize request to the running master | ||
| // and returns its reported version string (e.g. "2.1.51"). | ||
| // Returns "" on any error (timeout, parse failure, etc.). | ||
| func QueryMasterVersion(port int) string { | ||
| payload := map[string]any{ | ||
| "jsonrpc": "2.0", | ||
| "id": "version-check", | ||
| "method": "initialize", | ||
| "params": map[string]any{ | ||
| "protocolVersion": "2025-03-26", | ||
| "clientInfo": map[string]string{ | ||
| "name": "ragcode-proxy", | ||
| "version": "1.0.0", | ||
| }, | ||
| "capabilities": map[string]any{}, | ||
| }, | ||
| } | ||
|
|
||
| body, err := json.Marshal(payload) | ||
| if err != nil { | ||
| return "" | ||
| } | ||
|
|
||
| url := fmt.Sprintf("http://127.0.0.1:%d/mcp", port) | ||
| req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body)) | ||
| if err != nil { | ||
| return "" | ||
| } | ||
| req.Header.Set("Content-Type", "application/json") | ||
| req.Header.Set("Accept", "application/json, text/event-stream") | ||
|
|
||
| client := &http.Client{Timeout: 5 * time.Second} | ||
| resp, err := client.Do(req) | ||
| if err != nil { | ||
| return "" | ||
| } | ||
| defer resp.Body.Close() | ||
|
|
||
|
doITmagic marked this conversation as resolved.
|
||
| if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted { | ||
| return "" | ||
| } | ||
|
|
||
| bodyBytes, err := io.ReadAll(resp.Body) | ||
| if err != nil { | ||
| return "" | ||
| } | ||
|
|
||
| // Parse the response — may be direct JSON or SSE-wrapped. | ||
| var result map[string]any | ||
| if err := json.Unmarshal(bodyBytes, &result); err != nil { | ||
| // Not direct JSON — try SSE extraction if Content-Type matches. | ||
| contentType := resp.Header.Get("Content-Type") | ||
| if strings.HasPrefix(contentType, "text/event-stream") { | ||
| payload := extractSSEPayload(bodyBytes) | ||
| if payload == nil { | ||
| return "" | ||
| } | ||
| if err := json.Unmarshal(payload, &result); err != nil { | ||
| return "" | ||
| } | ||
| } else { | ||
| return "" | ||
| } | ||
| } | ||
|
doITmagic marked this conversation as resolved.
|
||
|
|
||
| // Navigate: result.serverInfo.version | ||
| res, ok := result["result"].(map[string]any) | ||
| if !ok { | ||
| return "" | ||
| } | ||
| serverInfo, ok := res["serverInfo"].(map[string]any) | ||
| if !ok { | ||
| return "" | ||
| } | ||
| // Validate this is actually a RagCode instance, not another MCP server. | ||
| // Prevents misidentifying and killing unrelated services on the same port. | ||
| name, _ := serverInfo["name"].(string) | ||
| if name != "ragcode" { | ||
| return "" | ||
| } | ||
| version, _ := serverInfo["version"].(string) | ||
|
doITmagic marked this conversation as resolved.
|
||
| return version | ||
| } | ||
|
|
||
| // KillProcessOnPort finds the process listening on the given port and kills it. | ||
| // On Linux/macOS uses lsof + kill; on Windows uses netstat + taskkill. | ||
| // Waits up to 3 seconds for the port to become free. | ||
| func KillProcessOnPort(port int) { | ||
| addr := fmt.Sprintf("%d", port) | ||
|
|
||
| if runtime.GOOS == "windows" { | ||
| // netstat -ano | findstr :3000 → extract PID → taskkill | ||
| out, err := exec.Command("cmd", "/C", fmt.Sprintf("netstat -ano | findstr :%s", addr)).Output() | ||
| if err == nil { | ||
| for _, line := range strings.Split(string(out), "\n") { | ||
| fields := strings.Fields(strings.TrimSpace(line)) | ||
| if len(fields) >= 5 { | ||
| pid := fields[len(fields)-1] | ||
| _ = exec.Command("taskkill", "/F", "/PID", pid).Run() | ||
| } | ||
| } | ||
| } | ||
| } else { | ||
| // lsof -ti :3000 → kill each PID | ||
| out, err := exec.Command("lsof", "-ti", fmt.Sprintf(":%s", addr)).Output() | ||
| if err == nil { | ||
| for _, pid := range strings.Fields(string(out)) { | ||
| if pid != "" { | ||
| logger.Instance.Info("Killing old master process PID=%s on port %d", pid, port) | ||
| _ = exec.Command("kill", pid).Run() | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // Wait for port to become free (max 3s) | ||
| for i := 0; i < 6; i++ { | ||
| if !PortIsOccupied(port) { | ||
| return | ||
| } | ||
| time.Sleep(500 * time.Millisecond) | ||
| } | ||
|
|
||
| // Force kill if still occupied | ||
| if runtime.GOOS != "windows" { | ||
| out, err := exec.Command("lsof", "-ti", fmt.Sprintf(":%d", port)).Output() | ||
| if err == nil { | ||
| for _, pid := range strings.Fields(string(out)) { | ||
| if pid != "" { | ||
| logger.Instance.Warn("Force-killing stubborn process PID=%s on port %d", pid, port) | ||
| _ = exec.Command("kill", "-9", pid).Run() | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| time.Sleep(500 * time.Millisecond) | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.