Summary
A relations.callers call on a Go repo with gopls attached killed the entire daemon with fatal error: concurrent map writes. This is a Go fatal, not a panic, so the per-handler recover firewall in wrapToolHandlerMode cannot catch it: the process dies and every session's MCP transport drops, not just the offending call.
Stack (observed, daemon built from origin/main @ 217391f)
fatal error: concurrent map writes
goroutine 279 [running]:
internal/runtime/maps.fatal(...)
/usr/local/go/src/runtime/panic.go:1195 +0x20
github.com/zzet/gortex/internal/semantic/lsp.(*Provider).openDocument(...)
internal/semantic/lsp/provider.go:2789 +0xf8
github.com/zzet/gortex/internal/semantic/lsp.(*Provider).EnsureFileOpen(...)
internal/semantic/lsp/provider.go:2961 +0x64
github.com/zzet/gortex/internal/semantic/lsp.(*Provider).ConfirmSymbolRefs(...)
internal/semantic/lsp/provider.go:3007 +0x170
github.com/zzet/gortex/internal/mcp.(*Server).confirmSymbolRefsOnDemand(...)
internal/mcp/tools_lsp.go:88 +0x144
github.com/zzet/gortex/internal/mcp.(*Server).handleGetCallers(...)
internal/mcp/tools_core.go:2681 +0x478
github.com/zzet/gortex/internal/mcp.(*Server).invokeFacadeSpec(...)
internal/mcp/facade_tools.go:1112 +0x11f4
github.com/zzet/gortex/internal/mcp.(*Server).handleFacade(...)
internal/mcp/facade_tools.go:618 +0x1100
...
internal/mcp/tool_deadline.go:154 +0x2e4
Root cause
openDocument releases docMu after its check-then-act on openDocs, then writes sourceCache with no lock at all (provider.go:2773-2789):
p.docMu.Lock()
if p.openDocs[absPath] {
p.docMu.Unlock()
return nil
}
p.docMu.Unlock() // <- lock released here
content, err := os.ReadFile(absPath)
if err != nil {
return err
}
if p.sourceCache == nil {
p.sourceCache = map[string][]byte{} // <- unguarded map write
}
p.sourceCache[absPath] = content // <- unguarded map write
Both facts are documented in the file, which is why this reads as an invariant that quietly stopped holding rather than an oversight:
provider.go:91 — "docMu guards docVersions / openDocs / lastDiag" — sourceCache is deliberately not in that list.
provider.go:2030 — "sourceCache is the unsynchronised interactive-navigation cache".
sourceCache was safe while it was only touched from a single interactive path. confirmSymbolRefsOnDemand now reaches openDocument from the tool-request path, where two concurrent relations.callers / relations.usages calls on different files race directly on the map. The check-then-act on openDocs makes it worse: two goroutines can both pass the openDocs gate for the same file and race on the same key.
applyDocumentEdit at :2837-2840 and delete(p.sourceCache, ...) at :2864 are the other unguarded writers; the reader at :3134-3137 is unguarded too.
Reproduction
Tracked Go repo, gopls attached, then a relations.callers facade call concurrent with other tool traffic. Observed once during PR #705 validation on a 6-file fixture — small repos are enough, no special load needed.
Diagnosing it from the outside
The fatal lands in the daemon log ($GORTEX_HOME/cache/daemon.log, or the daemon start stdout). The failing call itself surfaces as a timeout or a cancelled request; the first clear symptom is that the next call returns daemon unavailable: dial unix ... connection refused. So the stack trace in the log is the only signal that this happened rather than an ordinary hang.
Suggested fix
Bring sourceCache under docMu (or give it its own mutex) at all four sites, and close the openDocs check-then-act so one file is opened once. Update the two comments that currently promise the opposite.
Unrelated to #705 — found while validating it.
Summary
A
relations.callerscall on a Go repo with gopls attached killed the entire daemon withfatal error: concurrent map writes. This is a Go fatal, not a panic, so the per-handler recover firewall inwrapToolHandlerModecannot catch it: the process dies and every session's MCP transport drops, not just the offending call.Stack (observed, daemon built from
origin/main@ 217391f)Root cause
openDocumentreleasesdocMuafter its check-then-act onopenDocs, then writessourceCachewith no lock at all (provider.go:2773-2789):Both facts are documented in the file, which is why this reads as an invariant that quietly stopped holding rather than an oversight:
provider.go:91— "docMu guards docVersions / openDocs / lastDiag" —sourceCacheis deliberately not in that list.provider.go:2030— "sourceCache is the unsynchronised interactive-navigation cache".sourceCachewas safe while it was only touched from a single interactive path.confirmSymbolRefsOnDemandnow reachesopenDocumentfrom the tool-request path, where two concurrentrelations.callers/relations.usagescalls on different files race directly on the map. The check-then-act onopenDocsmakes it worse: two goroutines can both pass theopenDocsgate for the same file and race on the same key.applyDocumentEditat:2837-2840anddelete(p.sourceCache, ...)at:2864are the other unguarded writers; the reader at:3134-3137is unguarded too.Reproduction
Tracked Go repo, gopls attached, then a
relations.callersfacade call concurrent with other tool traffic. Observed once during PR #705 validation on a 6-file fixture — small repos are enough, no special load needed.Diagnosing it from the outside
The fatal lands in the daemon log (
$GORTEX_HOME/cache/daemon.log, or thedaemon startstdout). The failing call itself surfaces as a timeout or a cancelled request; the first clear symptom is that the next call returnsdaemon unavailable: dial unix ... connection refused. So the stack trace in the log is the only signal that this happened rather than an ordinary hang.Suggested fix
Bring
sourceCacheunderdocMu(or give it its own mutex) at all four sites, and close theopenDocscheck-then-act so one file is opened once. Update the two comments that currently promise the opposite.Unrelated to #705 — found while validating it.