|
| 1 | +# Execution Log — 046-local-launcher-for-http-sse |
| 2 | + |
| 3 | +State maintained per `CLAUDE.md` autonomous-operation requirement. Each |
| 4 | +session appends a dated entry; do not rewrite history. |
| 5 | + |
| 6 | +## 2026-05-10 — Initial scaffold (Roman + Claude) |
| 7 | + |
| 8 | +**Status**: Phase 0 + Phase 1 code landed in working tree (uncompiled — |
| 9 | +sandbox network blocks `proxy.golang.org`, see end of log). Phase 2 partial. |
| 10 | + |
| 11 | +### Files added |
| 12 | + |
| 13 | +- `internal/upstream/launcher/launcher.go` — `Spec`, `Handle`, `Spawn`. Owns the |
| 14 | + child's lifecycle (Stop with SIGTERM → grace → SIGKILL fallback, Wait, Done, |
| 15 | + Pid). Pumps stdout+stderr line-by-line into a caller-supplied `io.Writer`, |
| 16 | + one Write per line so a zap-bridge sink produces one log entry per line. |
| 17 | +- `internal/upstream/launcher/launcher_unix.go` — Setpgid + signal-the-pgroup |
| 18 | + for SIGTERM/SIGKILL on Linux/macOS. |
| 19 | +- `internal/upstream/launcher/launcher_windows.go` — best-effort stubs |
| 20 | + (matches the existing `process_windows.go` TODO; Job Objects are a |
| 21 | + follow-up). |
| 22 | +- `internal/upstream/launcher/wait.go` — `WaitForURL` does TCP-dial polling |
| 23 | + rather than HTTP GET (gotcha #2 in plan: SSE endpoints stream forever and |
| 24 | + break HTTP-GET probes). |
| 25 | +- `internal/upstream/launcher/wait_test.go` — 6 cases (immediately bound, |
| 26 | + bound late, never bound, ctx-canceled, bad URLs, default-port inference). |
| 27 | +- `internal/upstream/launcher/launcher_test.go` — 7 cases (graceful exit, |
| 28 | + SIGKILL fallback when SIGTERM is trapped, Done on natural exit, exit-code |
| 29 | + capture via `*exec.ExitError`, Stop idempotency, log sink capture, nil |
| 30 | + guards). |
| 31 | +- `internal/upstream/launcher/integration_test.go` — full Spawn + WaitForURL |
| 32 | + with a python-listener subprocess; skips when python3 is missing or on |
| 33 | + Windows. (Pure Go testdata helper would be cleaner — TODO.) |
| 34 | +- `internal/upstream/core/connection_launcher.go` — `connectWithLauncher`, |
| 35 | + `stopLauncher`, `watchLauncher`, `buildLauncherCmd`, `loggerWriter`. |
| 36 | + |
| 37 | +### Files modified |
| 38 | + |
| 39 | +- `internal/config/config.go` — `LauncherWaitTimeout Duration` on |
| 40 | + `ServerConfig`. Default 30s when zero/unset. |
| 41 | +- `internal/config/merge.go` — `CopyServerConfig` carries the new field. |
| 42 | +- `internal/upstream/core/client.go` — `launcherHandle launcher.Handle` and |
| 43 | + `launcherCIDFile string` on `Client`; new import. |
| 44 | +- `internal/upstream/core/connection.go` — pre-transport launcher dispatch |
| 45 | + for `http`/`sse`/`streamable-http` when `Command != ""`. Stops launcher |
| 46 | + in the connect-failure cleanup path. |
| 47 | +- `internal/upstream/core/connection_lifecycle.go` — `stopLauncher` after |
| 48 | + the MCP-client close in Disconnect (so the child sees the network |
| 49 | + transport go away first); also clears `processCmd`. |
| 50 | +- `docs/configuration.md` — new "Locally-launched HTTP / SSE servers" |
| 51 | + section + back-compat behaviour matrix; `launcher_wait_timeout` row in |
| 52 | + the Server Fields table. |
| 53 | +- `docs/cli-management-commands.md` — restart-semantics note covering the |
| 54 | + launcher stop-then-start order. |
| 55 | + |
| 56 | +### Decisions / assumptions |
| 57 | + |
| 58 | +1. **Stdio path untouched.** Plan's Phase 0 contemplated lifting env/Docker |
| 59 | + plumbing out of `connection_stdio.go` and routing stdio through |
| 60 | + `launcher.Spawn`. Doing that requires reworking how mcp-go owns the |
| 61 | + stdio process (mcp-go's `Stdio` transport spawns via a `CommandFunc` it |
| 62 | + controls — externally-spawned children can't be wired into it without |
| 63 | + patching the upstream library). To honour the spirit of "Docker-isolation |
| 64 | + logic must live in one place" without that reshuffling, the new |
| 65 | + `buildLauncherCmd` reuses the same Client methods (`setupDockerIsolation`, |
| 66 | + `injectEnvVarsIntoDockerArgs`, `insertCidfileIntoShellDockerCommand`, |
| 67 | + `wrapWithUserShell`) the stdio path already calls. Single source of |
| 68 | + truth, but no double-spawn risk. |
| 69 | + |
| 70 | +2. **Launcher-managed children stay invisible to stdio cleanup helpers.** |
| 71 | + `connectWithLauncher` deliberately does NOT set `c.processCmd` / |
| 72 | + `c.processGroupID`. The `launcher.Handle` owns lifecycle; setting those |
| 73 | + would let stdio's `killProcessGroup` race with `Handle.Stop`. This is a |
| 74 | + minor deviation from the original plan (which suggested wiring the same |
| 75 | + process-group tracking) — the result is cleaner ownership. |
| 76 | + |
| 77 | +3. **Health check is a TCP dial.** Per the plan's gotcha #2. |
| 78 | + `addrFromURL` infers default ports for http/https/ws/wss; rejects |
| 79 | + unknown schemes early so misconfigurations surface fast. |
| 80 | + |
| 81 | +4. **StopGrace default is 5s.** Plan asked for an explicit decision (open |
| 82 | + question #2). 5s matches `processGracefulTimeout` in |
| 83 | + `internal/upstream/core/connection.go`. No per-server override yet — |
| 84 | + `Spec.StopGrace` is plumbed but not exposed in `ServerConfig`. Promote to |
| 85 | + config if a real-world server needs more. |
| 86 | + |
| 87 | +5. **Crash-while-connected → Disconnect.** `watchLauncher` calls the |
| 88 | + `Client.Disconnect()` path on unexpected child exit (gotcha #6). |
| 89 | + Existing reconnect logic in `internal/upstream/managed` then handles |
| 90 | + the come-back attempt — no separate launcher-internal restart loop |
| 91 | + (open question #3 settled toward "defer to transport-level reconnect"). |
| 92 | + |
| 93 | +6. **Stop ctx on shutdown.** `stopLauncher` currently uses |
| 94 | + `context.WithTimeout(context.Background(), 10s)` everywhere. Plan |
| 95 | + open question #4 — accept this default; raise the limit if shutdown |
| 96 | + really needs to wait for slow Docker stop. |
| 97 | + |
| 98 | +### Verification round 1 (2026-05-11) |
| 99 | + |
| 100 | +After `sbx policy allow network proxy.golang.org,sum.golang.org` was set: |
| 101 | + |
| 102 | +| Command | Result | |
| 103 | +|---|---| |
| 104 | +| `GOTOOLCHAIN=local go vet ./internal/upstream/...` | ✅ clean | |
| 105 | +| `GOTOOLCHAIN=local go test ./internal/upstream/launcher/...` | ✅ 15/15 | |
| 106 | +| `GOTOOLCHAIN=local go test ./internal/upstream/...` | ✅ all packages | |
| 107 | +| `GOTOOLCHAIN=local go test ./internal/config/...` | ✅ | |
| 108 | +| `go test -race` | ⚠️ blocked — cgo (gcc) not installed in sandbox; user can run on host | |
| 109 | +| `go build ./cmd/mcpproxy` | ❌ blocked — needs `storage.googleapis.com` (some Go modules CDN-served from there); user must add `sbx policy allow network storage.googleapis.com` | |
| 110 | + |
| 111 | +### Bugs found + fixed during verification round 1 |
| 112 | + |
| 113 | +1. **Deadlock in connect-failure cleanup.** `Connect` holds `c.mu` for its |
| 114 | + entire duration; my original failure-path call to `c.stopLauncher(...)` |
| 115 | + re-acquired the same lock → hang. Fixed by inlining the stop sequence |
| 116 | + in `connection.go`'s cleanup branch (read fields under the held lock, |
| 117 | + release `c.mu` around `handle.Stop()`, reacquire before return). |
| 118 | +2. **`connectWithLauncher` redundant locking.** Same root cause — |
| 119 | + `connectWithLauncher` is called from `Connect` which already holds |
| 120 | + `c.mu`. Removed the inner `c.mu.Lock()/Unlock()` for the launcher |
| 121 | + field writes; the wait-for-url failure path still releases the lock |
| 122 | + around the blocking `handle.Stop()` and reacquires before returning. |
| 123 | +3. **`bytes.Buffer` LogSink race.** Test failures from the stdout pump, |
| 124 | + stderr pump, and the startup-banner write all racing on a single |
| 125 | + `*bytes.Buffer` in tests. Fixed by wrapping `LogSink` internally with |
| 126 | + a `serializedWriter` (mutex around `Write`). zap-bridge in production |
| 127 | + is already thread-safe, so this is a robustness fix for test sinks |
| 128 | + and any future single-writer adapters. |
| 129 | +4. **SIGKILL-fallback test could detect "ready" in the banner.** The |
| 130 | + launcher startup banner echoes the script source verbatim, so any |
| 131 | + marker token literally present in the script also matched in the |
| 132 | + banner — making the test think the trap was installed before the |
| 133 | + shell even ran. Fixed by using a shell-substituted marker |
| 134 | + (`__LNCTICK__:$$`) and a regex detector (`__LNCTICK__:[0-9]+`). |
| 135 | +5. **`bad scheme + explicit port` test case.** Test asserted error on |
| 136 | + `ftp://example.com:21/foo` but the launcher correctly accepts any |
| 137 | + scheme when the port is explicit (user took responsibility). Removed |
| 138 | + that case; replaced with the actually-invalid `ftp://example.com/foo`. |
| 139 | + |
| 140 | +### Outstanding network blocker |
| 141 | + |
| 142 | +``` |
| 143 | +sbx policy allow network storage.googleapis.com |
| 144 | +``` |
| 145 | + |
| 146 | +Needed for `go build ./cmd/mcpproxy` to fetch Bleve/Roaring/etc. CDN-backed |
| 147 | +modules. Once allowed, the verification commands are: |
| 148 | + |
| 149 | +``` |
| 150 | +GOTOOLCHAIN=local go build ./cmd/mcpproxy |
| 151 | +./scripts/test-api-e2e.sh # optional smoke test |
| 152 | +``` |
| 153 | + |
| 154 | +### Outstanding follow-ups (post-PR) |
| 155 | + |
| 156 | +- Replace `integration_test.go`'s python-shellout with a Go test-binary |
| 157 | + helper invoked via `os.Args` re-entry pattern, so the test runs on any |
| 158 | + CI that has Go (which is all of them). Plan called for a tiny binary in |
| 159 | + `internal/upstream/launcher/testdata/`. |
| 160 | +- Extend `scripts/test-api-e2e.sh` with a launcher-flavoured server (plan |
| 161 | + Phase 2 item). |
| 162 | +- Phase 3 (post-merge): `{port}` templating in `args` / `url`, per-launcher |
| 163 | + custom health probe, exponential backoff for repeated launcher crashes. |
0 commit comments