Subject: Re: [PATCH] config: retry acquiring config.lock for 100ms

From: Patrick Steinhardt <[email-masked]>
Date: Wed, 8 Apr 2026 12:34:49 +0200

On Fri, Apr 03, 2026 at 12:01:35PM +0200, Joerg Thalheim wrote:
> From: Jörg Thalheim <[email-masked]>
> 
> When multiple processes write to a config file concurrently, they
> contend on its ".lock" file, which is acquired via open(O_EXCL) with
> no retry. The losers fail immediately with "could not lock config
> file". Two processes writing unrelated keys (say, "branch.a.remote"
> and "branch.b.remote") have no semantic conflict, yet one of them
> fails for a purely mechanical reason.
> 
> This bites in practice when running `git worktree add -b` concurrently
> against the same repository. Each invocation makes several writes to
> ".git/config" to set up branch tracking, and tooling that creates
> worktrees in parallel sees intermittent failures. Worse, `git worktree
> add` does not propagate the failed config write to its exit code: the
> worktree is created and the command exits 0, but tracking
> configuration is silently dropped.

This very much sounds like a bug that is worth fixing independently.

> The lock is held only for the duration of rewriting a small file, so
> retrying for 100 ms papers over any realistic contention while still
> failing fast if a stale lock has been left behind by a crashed
> process. This mirrors what we already do for individual reference
> locks (4ff0f01cb7 (refs: retry acquiring reference locks for 100ms,
> 2017-08-21)).

Famous last words :) Experience tells me that any timeout value that
isn't excessive will eventually be hit in some production system. Which
raises the question whether we want to make the timeout configurable,
similar to "core.filesRefLockTimeout" and "core.packedRefsTimeout".

> diff --git a/config.c b/config.c
> index 156f2a24fa..f7aff8725d 100644
> --- a/config.c
> +++ b/config.c
> @@ -2903,6 +2903,14 @@ char *git_config_prepare_comment_string(const char *comment)
>  	return prepared;
>  }
>  
> +/*
> + * How long to retry acquiring config.lock when another process holds it.
> + * The lock is held only for the duration of rewriting a small file, so
> + * 100 ms covers any realistic contention while still failing fast if
> + * a stale lock has been left behind by a crashed process.
> + */
> +#define CONFIG_LOCK_TIMEOUT_MS 100
> +
>  static void validate_comment_string(const char *comment)
>  {
>  	size_t leading_blanks;
> @@ -2986,7 +2994,8 @@ int repo_config_set_multivar_in_file_gently(struct repository *r,
>  	 * The lock serves a purpose in addition to locking: the new
>  	 * contents of .git/config will be written into it.
>  	 */
> -	fd = hold_lock_file_for_update(&lock, config_filename, 0);
> +	fd = hold_lock_file_for_update_timeout(&lock, config_filename, 0,
> +					       CONFIG_LOCK_TIMEOUT_MS);
>  	if (fd < 0) {
>  		error_errno(_("could not lock config file %s"), config_filename);
>  		ret = CONFIG_NO_LOCK;
> @@ -3331,7 +3340,8 @@ static int repo_config_copy_or_rename_section_in_file(
>  	if (!config_filename)
>  		config_filename = filename_buf = repo_git_path(r, "config");
>  
> -	out_fd = hold_lock_file_for_update(&lock, config_filename, 0);
> +	out_fd = hold_lock_file_for_update_timeout(&lock, config_filename, 0,
> +						   CONFIG_LOCK_TIMEOUT_MS);
>  	if (out_fd < 0) {
>  		ret = error(_("could not lock config file %s"), config_filename);
>  		goto out;

Okay, so we now handle this more gracefully with concurrent writers. But
even if we handle that more gracefully, we still don't really have any
guarantee that the outcome will be reasonable, or even close.

The main difference to the locking timeouts we have for refs is that we
have an easy way to check for conflicts there: we simpliy verify that
the refs we want to update haven't moved from their expected value. We
have no such thing for our configuration though, so two processes that
race with one another will happily overwrite each other's changes.

Honestly though, I'm not really sure what to make with this. We could
of course also add some validation that the configuration we want to set
hasn't been modified meanwhile. But that would now lead to a situation
where we have to update every single caller in our tree to make use of
the new mechanism, which would be a bunch of work.

And adding the timeout doesn't really change the status quo, either. We
already have the case that we'll happily overwrite changes made by
concurrent processes. The only thing that changes is that we make it
more likely for concurrent changes to succeed.

So I'm a bit cautious, but I think overall I'm fine with this?

Thanks!

Patrick