Skip to content

Architectural cleanup: extract httpapi, fix sync supervisor races, installer --version#47

Merged
aniongithub merged 8 commits into
mainfrom
refactor/rough-edges
May 23, 2026
Merged

Architectural cleanup: extract httpapi, fix sync supervisor races, installer --version#47
aniongithub merged 8 commits into
mainfrom
refactor/rough-edges

Conversation

@aniongithub
Copy link
Copy Markdown
Owner

Follow-up to the architecture review of the codebase. Eight focused commits, each independently reviewable.

Summary

# Commit Type
1 refactor(wiki): drop dead extractTitle filename param cleanup
2 fix(service): remove stale '/mcp' endpoint advert in 'service start' doc bug
3 refactor(webui): rename mcp.ts -> api.ts naming
4 feat(sync): add Manager.Reload + Interval for hot-reload feature
5 refactor(http): extract handlers into internal/httpapi + hot-reload sync refactor
6 fix(http): make sync supervisor shutdown-safe; tighten encode logging concurrency bug
7 feat(installer): support pinning a specific release version feature
8 fix(installer): use pinned tag for all release assets, not just the binary bug

Highlights

internal/httpapi extraction (commits 4-6)

runHTTPServer in cmd/mind-map/main.go was ~270 LOC of inline closures over local state, none of which was unit-testable on its own. Extracted into a dedicated internal/httpapi package with a Server struct, 7 black-box tests covering the full mux + middleware stack against a real *wiki.Wiki.

Hot-reload sync (commit 5)

PUT /api/settings previously was a no-op until the user hit /api/restart and the binary self-exec'd. Now the supervisor reconciles enable/disable, interval changes, and in-place mapping reloads live. /api/restart still works as an escape hatch.

Shutdown-safety bug fix (commit 6)

The naive supervisor introduced two real concurrency holes:

  1. Sync used context.Background(). An HTTP-level shutdown didn't propagate into in-flight git operations. On /api/restart, the old process could still be mid-git-op when the new process started — both touching the same shadow clone directory. Real risk of corruption.

  2. sync.Manager.Start is not thread-safe. It writes m.cancel/m.done without locking. In the narrow window where applyConfig stashed a new manager and released s.mu before Start ran, shutdown() could grab the same manager and call Stop concurrently with Start. Race detector territory.

Fixed by:

  • rootCtx/rootCancel owned by the server; all sync contexts derive from it
  • actionMu mutex held strictly around Start/Stop calls so they can never race
  • shuttingDown flag so a settings PUT during teardown writes config (preserves intent for next start) but refuses to spawn a fresh sync goroutine
  • New TestShutdownStopsSync and TestSettingsChangeDuringShutdown tests, all green under go test -race

Installer --version (commits 7-8)

Both scripts now accept a version pin for testing prereleases without making them latest:

curl ... | bash -s -- --version v0.49
MINDMAP_VERSION=v0.49 curl ... | bash
$env:MINDMAP_VERSION = "v0.49"
irm ... | iex

Commit 8 fixes a subtle URL bug: GitHub's /releases/latest/download/<asset> is a redirect, not a path you can substitute a tag into. Versioned assets live at /releases/download/<tag>/<asset>. The binary URL was already correct; the PowerShell self-elevation re-fetch and both scripts' SKILL.md fetches were not. Now everything fetched by the installer matches the binary's version.

Verification

All run inside the project's devcontainer:

  • go vet ./... clean
  • go test ./... — every package green
  • go test -race ./... — clean (the race detector caught earlier iterations of the supervisor fix)
  • webui builds (npm run build)
  • Binary builds and --help works
  • bash -n install.sh syntax-clean; logic walkthrough validates flag/env/precedence
  • PowerShell: hand-reviewed (no pwsh in devcontainer)

Behavior changes worth noting on review

  1. PUT /api/settings now applies sync config live instead of being a no-op until /api/restart. Disabling, enabling, swapping remotes, and interval changes all propagate without restart.
  2. mind-map service start no longer prints the MCP endpoint: .../mcp line — there's no /mcp route, so it was misleading.
  3. PUT /api/settings log line changed from "settings saved and applied" to "settings saved" (it may legitimately skip the apply during shutdown).
  4. writeJSON encode errors now log at Warn (was Debug) — a persistent encode failure usually points at a real bug.

Everything else is pure refactor — same routes, same JSON shapes, same exit codes.

extractTitle's filename fallback was unreachable — every caller passed
"" and applied the filename fallback itself (index.go:92, :173). Drop
the parameter, simplify the contract to 'frontmatter title -> first #
heading -> ""'.

While here, tighten parsePage:
- remove unused doc and fmEnd locals
- drop the now-redundant second return value from stripFrontmatter
- inline the single use of links into the struct literal
The line printed 'MCP endpoint: http://<addr>/mcp' but no /mcp route is
registered on the HTTP server — MCP is stdio-only. Drop the misleading
output.
The file has always been a plain REST client for /api/* endpoints. The
'mcp' name was a confusing inheritance from earlier design — the web UI
never speaks MCP (only AI agents do, via stdio).

Rename the module and tighten its doc comment to make the boundary
explicit. All imports updated; no behavior change.
Adds two small methods so an HTTP supervisor can update a running
sync.Manager without bouncing the whole process:

- Reload(newCfg): swap the in-memory config and rebuild targets under
  the existing lock. Background loop keeps running.
- Interval(): expose the captured tick interval so callers can detect
  changes that require a full restart (the ticker is created at Start
  time and can't be retuned in place).

Both methods are read-mostly and respect the existing m.mu discipline.
Move every HTTP handler out of cmd/mind-map/main.go (~270 LOC of inline
closures over runHTTPServer's locals) into a dedicated internal/httpapi
package. main.go is now just wiring: open the wiki, load the config,
construct an httpapi.Server, listen.

The new package owns:

- All /api/* handlers as methods on *Server (testable on their own).
- A small sync supervisor (startSyncLocked / stopSyncLocked /
  applyConfigLocked) that reconciles the running sync.Manager with the
  current config: enabled<->disabled transitions, interval changes, and
  in-place mapping reloads.
- The /api/settings PUT handler now hot-applies config changes through
  the supervisor. Previously, settings only took effect after the user
  hit /api/restart and the binary self-exec'd. /api/restart still works
  (it's the only way to apply changes that need a full process restart),
  but the common case no longer requires it.
- The static file handler (embedded WebFS or 'not built' placeholder).

Adds 7 black-box tests covering create/get/list/search, update/delete,
validation errors, settings round-trip, sync supervisor reaction to
config changes, and the not-built placeholder. These exercise the full
mux + middleware stack against a real *wiki.Wiki on a t.TempDir().

No behavior change to the wire protocol — the OpenAPI surface is byte-
for-byte identical apart from /api/settings now applying changes live.
The supervisor introduced in the previous commit had two real concurrency
bugs that could surface under /api/restart or SIGINT:

1. Sync managers were started with context.Background() instead of a
   server-scoped context. An HTTP-level shutdown (StopCh closed) did not
   propagate into in-flight git operations — fetch/push could keep
   running past the point where the server thought it was gone, and on
   /api/restart the new process could touch the same shadow clone
   directory concurrently with the old one.

2. sync.Manager.Start writes m.cancel/m.done without internal locking.
   The supervisor called Start under s.mu and Stop under s.mu, but in
   the (real) race where applyConfig stashes a new mgr and releases the
   lock before its Start runs, shutdown could grab the same mgr and
   call Stop on it concurrently with applyConfig's Start. That's a data
   race on the sync.Manager fields per the sync package's contract.

Fixes:

- Introduce rootCtx/rootCancel on the Server. All sync manager contexts
  are derived from rootCtx so HTTP shutdown propagates into git.
- Add actionMu, a separate mutex held strictly around Start/Stop calls.
  applyConfig and shutdown both acquire it for the blocking work, so
  the two can never race on the same manager.
- Add shuttingDown flag set under s.mu so applyConfig short-circuits
  if a settings PUT lands during teardown — the config still hits disk
  (for the next start) but no manager goroutine is spawned.
- runAction re-checks shuttingDown under s.mu before Start, avoiding a
  noisy log + wasted MkdirAll in the race window where shutdown wins
  the actionMu after applyConfig released s.mu.

Also:

- writeJSON now logs at Warn (not Debug) when JSON encoding fails. A
  persistent encode failure points at a real bug; Debug was too quiet.
- 'settings saved and applied' log message simplified to 'settings
  saved' since applyConfig may legitimately skip the apply during
  shutdown.

Tests:

- New TestShutdownStopsSync exercises the StopCh -> shutdown -> idempotent
  re-close path.
- New TestSettingsChangeDuringShutdown verifies a PUT racing with
  shutdown produces no orphan sync goroutine; the WARN log fires; the
  status endpoint reports no manager.
- All tests pass under go test -race (the race detector caught earlier
  iterations of this fix; the current design is clean).
Both install scripts now accept an explicit version, useful for testing
prereleases without making them latest:

  curl ... | bash -s -- --version v0.49
  MINDMAP_VERSION=v0.49 curl ... | bash

  irm ... | iex   # with $env:MINDMAP_VERSION = 'v0.49' set first

Resolution precedence: CLI flag/param > env var > GitHub 'latest' API.

PowerShell specifics:

- Add a -Version parameter (PowerShell-native, used in non-piped form).
- When the script self-elevates via Start-Process, propagate the version
  pin through MINDMAP_VERSION so the elevated session sees it. Param
  binding doesn't survive 'irm | iex'.
- Document the env var workaround in the .EXAMPLE block of the help
  header for the piped form.

No change to the default behavior — both scripts still install the
latest release if no version is specified.
…inary

GitHub's /releases/latest/download/<asset> path is a redirect that only
points at the latest release. When a user pins --version v0.49, swapping
'latest' for the tag in the redirect path produces a 404 — versioned
assets live at /releases/download/<tag>/<asset> instead. The previous
commit got the binary URL right (it was already tag-based) but left two
'latest'-tied fetches:

- install.ps1's self-relaunch (admin elevation) re-downloads install.ps1.
  If a version was pinned, the elevated session was still grabbing the
  latest install.ps1. The propagated MINDMAP_VERSION made the binary
  correct, but the script logic might not have matched.

- Both scripts fetched SKILL.md from raw.githubusercontent.com/.../main/.
  SKILL.md describes the binary's tool surface; pinning v0.49 while
  installing main's SKILL.md is a real mismatch for users testing
  prereleases or rolling back.

Both fetches now use the same git ref / tag as the binary. Tags work as
git refs on raw.githubusercontent.com, so SKILL_URL just substitutes
${VERSION}. install.ps1's elevation path picks the correct URL shape
based on whether $Version is set.
@aniongithub aniongithub merged commit ace0ba0 into main May 23, 2026
1 check passed
@aniongithub aniongithub deleted the refactor/rough-edges branch May 23, 2026 08:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant