Repository navigation
fix(git): stop mutating go-git's process-global transport registry - #13
Merged
Merged
Conversation
git-provider called gitclient.InstallProtocol("https", ...) around every git
operation, via setDefaultHTTPSClient/setCustomHTTPSClientWithCookieJar. That
writes go-git's package-level client.Protocols map, which has no locking of its
own: InstallProtocol writes it and client.getTransport reads it on every remote
operation. The local clientMutex only guarded our own writes - it could not
guard go-git's reads.
With MaxConcurrentReconciles defaulting to 5, a write landing while another
worker was inside a clone is a fatal "concurrent map read and map write", which
kills the process outright: no recover, no deferred cleanup, no error returned.
Observed in production (git-provider 0.15.1):
fatal error: concurrent map read and map write
goroutine 178 [running]:
...transport/client.getTransport(...) client.go:42
...git-provider/internal/clients/git.IsFuncInGitCommitHistory git.go:376
...provider-runtime@v1.2.1/pkg/reconciler/reconciler.go:804
When that crash lands in the window between provider-runtime writing
krateo.io/external-create-pending and writing the create result, the managed
resource is wedged permanently on "cannot determine creation result - remove
the krateo.io/external-create-pending annotation if it is safe to proceed",
even though the remote push had already succeeded.
Changes:
- Purely local operations never open a transport, so they no longer touch the
registry at all: Exists (an fs.Stat), CurrentBranch, GetLatestCommit, Branch,
Commit, UpdateIndex. This removes the large majority of the writes; with no
git cookie configured, every write was a no-op restore to begin with.
- Remote operations take transportMu for the whole operation. Callers needing
no custom client take the read lock and share go-git's default client, so
concurrency is preserved in the common case; only the cookie-jar and
capability paths take the exclusive lock.
- GetLatestCommitRemote is split into a locking wrapper and a non-locking
getLatestCommitRemote, because clone calls it while already holding the lock
and sync.RWMutex is not reentrant.
- transport.UnsupportedCapabilities is a second process-global. It is now read
and written only on the Azure DevOps path, under the exclusive lock, and
clone restores it instead of leaking it to every later clone.
- Releasing restores githttp.DefaultClient, the exact singleton go-git ships,
rather than allocating an equivalent client each time.
Adds transport_test.go, which drives readers, cookie writers and capability
mutators concurrently and must stay green under -race.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JumEqTGGJ2hf3vjE3xFQsv
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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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.
The wedge
Repo/LocalResourceobjects stick atSynced=Falsewith:…even though the remote push had already succeeded.
LocalResource publish-pet-000sat wedged for ~6 days. Clearing the annotation by hand lets the publish finish immediately.Root cause — verified from a production crash dump
git-providermutates go-git's process-global transport registry around every operation, viasetDefaultHTTPSClient/setCustomHTTPSClientWithCookieJar.go-git's
plumbing/transport/client.Protocolsis a plain map with no locking of its own —InstallProtocolwrites it,getTransportreads it on every remote operation. OurclientMutexonly guarded our writes; it could not guard go-git's reads.With
MaxConcurrentReconcilesdefaulting to 5, a write landing while another worker is inside a clone is a fatal runtime error, which kills the process outright — norecover, no deferred cleanup, no error returned to the reconciler.From the running pod (
git-provider:0.15.1,exitCode: 2,reason: Error):Four concurrent
IsFuncInGitCommitHistory→Clonegoroutines were in flight in that same dump.How that becomes a permanent wedge
provider-runtimewritesexternal-create-pending, callsCreate, then writesexternal-create-succeeded/-failed(reconciler.go:942 / 977 / 1010). A fatal error between those two writes leaves a barependingannotation, and every later reconcile refuses to proceed (reconciler.go:766).This is the discriminator that rules out a "the wrong client got installed" theory: a bad client would make
Createreturn an error, which writesexternal-create-failedand retries. Only a process death can leave a barepending. On the cluster, the healthy objects show exactly thatfailed→ retry →succeededpattern, whilepublish-pet-000shows a lonepending.Note this is not fixable by bumping dependencies:
provider-runtime v1.2.1is the newest tag, andgo-git v5.19.2is the newest v5 (v6 is alpha-only).The fix
Exists(anfs.Stat!),CurrentBranch,GetLatestCommit,Branch,Commit,UpdateIndex. This removes the large majority of writes. With no git cookie configured, every one of those writes was a no-op restore to begin with.transportMufor the whole operation. Callers needing no custom client take the read lock and share go-git's default client, so concurrency is preserved in the common case; only the cookie-jar and capability paths take the exclusive lock.GetLatestCommitRemoteis split into a locking wrapper and a non-lockinggetLatestCommitRemote, becauseclonecalls it while already holding the lock andsync.RWMutexis not reentrant. (Commit→UpdateIndexandclone→Branchnested the same way; those inner calls are now lock-free local ops.)transport.UnsupportedCapabilitiesis a second process-global. It is now read/written only on the Azure DevOps path, under the exclusive lock — andclonenow restores it instead of leaking it to every later clone, which it previously never did.githttp.DefaultClient, the exact singleton go-git ships, rather than allocating an equivalent client each time.Incidental bugs this also fixes
The nested calls meant the inner op's
defer setDefaultHTTPSClient()restored the default client while the outer op was still running — so a cookie-configuredCommitlost its cookie jar partway through, single-threaded.clonehad an explicit re-install at line 547 working around exactly that.Testing
transport_test.godrives readers, cookie writers and capability mutators concurrently; it must stay green under-race. The whole suite passes withgo test -race ./....The pre-fix pattern reproduces the crash locally in isolation (concurrent
InstallProtocol+NewClient→fatal error: concurrent map writes), confirming the mechanism rather than just asserting it.Note for operators
This stops new wedges. Already-wedged objects still need their
krateo.io/external-create-pendingannotation removed by hand once the push is confirmed — the annotation is deliberately a manual safety gate.🤖 Generated with Claude Code
https://claude.ai/code/session_01JumEqTGGJ2hf3vjE3xFQsv