-
Notifications
You must be signed in to change notification settings - Fork 195
t/lib-httpd: make CGI test helpers concurrency-safe #2171
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -6,21 +6,43 @@ | |
| # | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Patrick Steinhardt wrote on the Git mailing list (how to reply to this email): On Fri, Jul 10, 2026 at 05:30:55PM +0000, Michael Montalbo via GitGitGadget wrote:
> diff --git a/t/lib-httpd/apply-one-time-script.sh b/t/lib-httpd/apply-one-time-script.sh
> index b1682944e2..adb9cec528 100644
> --- a/t/lib-httpd/apply-one-time-script.sh
> +++ b/t/lib-httpd/apply-one-time-script.sh
> @@ -6,21 +6,37 @@
> #
> # This can be used to simulate the effects of the repository changing in
> # between HTTP request-response pairs.
> -if test -f one-time-script
> -then
> - LC_ALL=C
> - export LC_ALL
> +#
> +# Apache can run this CGI for concurrent requests (for example a partial fetch
> +# that lazily fetches a missing object while the first response is still in
> +# flight), so the helper claims the marker atomically with a rename, and only
> +# once it has decided to modify the response. A request that loses the race
> +# finds the marker already gone and serves its response unchanged; no request
> +# is left emitting an empty body, which the server would report as HTTP 500.
> +# Scratch files are per-request ($$) so concurrent requests do not clobber each
> +# other.
> +#
> +# The script may run more than once: the marker is consumed when the response
> +# actually changes (the rename after "cmp"), not when the script runs, so a
> +# request whose response is not the targeted one runs the script, sees no
> +# change, and leaves the marker for a later request. That is safe because the
> +# scripts are stateless filters over the captured response.
>
> - "$GIT_EXEC_PATH/git-http-backend" >out
> - ./one-time-script out >out_modified
> +test -f one-time-script || exec "$GIT_EXEC_PATH/git-http-backend"
>
> - if cmp -s out out_modified
> - then
> - cat out
> - else
> - cat out_modified
> - rm one-time-script
> - fi
> +LC_ALL=C
> +export LC_ALL
> +
> +out=out.$$
> +modified=out-modified.$$
> +"$GIT_EXEC_PATH/git-http-backend" >"$out"
> +
> +if ./one-time-script "$out" 2>/dev/null >"$modified" &&
Is it intentional that we swallow stderr of this script now? We didn't
before. I assume that this is to swallow the error in case the script
got removed by the concurrent request?
PatrickThere was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Michael Montalbo wrote on the Git mailing list (how to reply to this email): On Tue, Aug 4, 2026 at 1:03 AM Patrick Steinhardt <ps@pks.im> wrote:
>
> > +
> > +out=out.$$
> > +modified=out-modified.$$
> > +"$GIT_EXEC_PATH/git-http-backend" >"$out"
> > +
> > +if ./one-time-script "$out" 2>/dev/null >"$modified" &&
>
> Is it intentional that we swallow stderr of this script now? We didn't
> before. I assume that this is to swallow the error in case the script
> got removed by the concurrent request?
>
Yes, you are correct on both counts. This is an intentional change
meant to swallow (an expected) stderr in case the script got removed
already by a concurrent request, but that is not clear on its own. I will
add an explanatory comment spelling this out. |
||
| # This can be used to simulate the effects of the repository changing in | ||
| # between HTTP request-response pairs. | ||
| if test -f one-time-script | ||
| then | ||
| LC_ALL=C | ||
| export LC_ALL | ||
| # | ||
| # Apache can run this CGI for several requests at the same time. For example, a | ||
| # partial fetch lazily fetches a missing object while the first response is | ||
| # still in flight. To stay correct, the helper removes the marker only after | ||
| # the response changes, and only with "rm" (without "-f"). The "rm" fails for | ||
| # every request except the one that removes the marker first. That request | ||
| # serves the modified body. Every other request serves its response unchanged. | ||
| # No request emits an empty body, which Apache would report as HTTP 500. | ||
| # | ||
| # A scratch file name includes the process ID ($$), so concurrent requests do | ||
| # not overwrite each other's files. | ||
| # | ||
| # The helper can run one-time-script more than once. It consumes the marker | ||
| # when the response changes (the "rm" after "cmp"), not when it runs the | ||
| # script. A request whose response is not the target runs the script, finds no | ||
| # change, and leaves the marker for a later request. This is safe because the | ||
| # scripts are stateless filters over the captured response. | ||
|
|
||
| "$GIT_EXEC_PATH/git-http-backend" >out | ||
| ./one-time-script out >out_modified | ||
| test -f one-time-script || exec "$GIT_EXEC_PATH/git-http-backend" | ||
|
|
||
| if cmp -s out out_modified | ||
| then | ||
| cat out | ||
| else | ||
| cat out_modified | ||
| rm one-time-script | ||
| fi | ||
| LC_ALL=C | ||
| export LC_ALL | ||
|
|
||
| out=out.$$ | ||
| modified=out-modified.$$ | ||
| "$GIT_EXEC_PATH/git-http-backend" >"$out" | ||
|
|
||
| # one-time-script can be gone here: a concurrent request may have consumed it | ||
| # since the "test -f" above. Then "./one-time-script" fails, the exit status | ||
| # selects the unmodified body, and "2>/dev/null" discards the expected | ||
| # "no such file" message. | ||
| if ./one-time-script "$out" 2>/dev/null >"$modified" && | ||
| ! cmp -s "$out" "$modified" && | ||
| rm one-time-script 2>/dev/null | ||
| then | ||
| cat "$modified" | ||
| else | ||
| "$GIT_EXEC_PATH/git-http-backend" | ||
| cat "$out" | ||
| fi | ||
| rm -f "$out" "$modified" | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,7 +3,7 @@ | |
| # Script to return HTTP 429 Too Many Requests responses for testing retry logic. | ||
| # Usage: /http_429/<test-context>/<retry-after-value>/<repo-path> | ||
| # | ||
| # The test-context is a unique identifier for each test to isolate state files. | ||
| # The test-context is a unique identifier for each test to isolate state directories. | ||
| # The retry-after-value can be: | ||
| # - A number (e.g., "1", "2", "100") - sets Retry-After header to that many seconds | ||
| # - "none" - no Retry-After header | ||
|
|
@@ -26,14 +26,24 @@ repo_path="${remaining#*/}" # Get rest (repo path) | |
| # The repo name is the first component before any "/" | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Junio C Hamano wrote on the Git mailing list (how to reply to this email): "Michael Montalbo via GitGitGadget" <gitgitgadget@gmail.com> writes:
> From: Michael Montalbo <mmontalbo@gmail.com>
>
> http-429.sh records "already returned 429 once" with a "test -f"
> followed by a "touch" of a shared state file. That check-then-act is not
> atomic: Apache can run this CGI for several requests at once, and two of
> them can both pass the "test -f" before either "touch"es, so both treat
> themselves as the first request. The retry flow that drives this
> endpoint is mostly sequential, so this has not been seen to fail, but
> the race is latent.
OK. And use of mkdir for atomicity is an obvious solution for such
a situtation.
> -if test -f "$state_file"
> +if test "$retry_after" != permanent && ! mkdir "$state" 2>/dev/null
> then
> # Already returned 429 once, forward to git-http-backend
> # Set PATH_INFO to just the repo path (without retry-after value)
> @@ -52,9 +55,6 @@ then
> exec "$GIT_EXEC_PATH/git-http-backend"
> fi
>
> -# Mark that we've returned 429
> -touch "$state_file"
> -There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Junio C Hamano wrote on the Git mailing list (how to reply to this email): "Michael Montalbo via GitGitGadget" <gitgitgadget@gmail.com> writes:
> -# Check if this is the first call (no state file exists)
> -if test -f "$state_file"
> +# Apache can run this CGI for concurrent requests, so the script decides
> +# whether this is the first call with a single atomic "mkdir": it succeeds for
> +# exactly one of any racing requests and fails for the rest. "permanent"
> +# always rate-limits and records no state.
> +if test "$retry_after" != permanent && ! mkdir "$state" 2>/dev/null
I think the last sentence in the above comment was meant to explain
why the new code checks the value of "$retry_after", but it is not
clear if it is needed for correctness (in other words, the original
was wrong to do "test -f && touch" but also was wrong to do so even
when "$retry_after" is set to "permanent), or if it is a mere
"optimization opportunity" you are taking advantage of. In either
case, it would be nice to see it explained in the proposed commit
log message.
Thanks.There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Michael Montalbo wrote on the Git mailing list (how to reply to this email): On Wed, Jul 8, 2026 at 1:02 PM Junio C Hamano <gitster@pobox.com> wrote:
>
> "Michael Montalbo via GitGitGadget" <gitgitgadget@gmail.com> writes:
>
> > -# Check if this is the first call (no state file exists)
> > -if test -f "$state_file"
> > +# Apache can run this CGI for concurrent requests, so the script decides
> > +# whether this is the first call with a single atomic "mkdir": it succeeds for
> > +# exactly one of any racing requests and fails for the rest. "permanent"
> > +# always rate-limits and records no state.
> > +if test "$retry_after" != permanent && ! mkdir "$state" 2>/dev/null
>
> I think the last sentence in the above comment was meant to explain
> why the new code checks the value of "$retry_after", but it is not
> clear if it is needed for correctness (in other words, the original
> was wrong to do "test -f && touch" but also was wrong to do so even
> when "$retry_after" is set to "permanent), or if it is a mere
> "optimization opportunity" you are taking advantage of. In either
> case, it would be nice to see it explained in the proposed commit
> log message.
>
It is needed for correctness, and I agree it is not very clear from the log
message / comment. I will spell out the reasoning for the change more
clearly in both.
Thanks for taking a look at this! |
||
| repo_name="${repo_path%%/*}" | ||
|
|
||
| # Use current directory (HTTPD_ROOT_PATH) for state file | ||
| # Create a safe filename from test_context, retry_after and repo_name | ||
| # This ensures all requests for the same test context share the same state file | ||
| # Store state in the current directory (HTTPD_ROOT_PATH). Build a safe name | ||
| # from test_context, retry_after, and repo_name, so that all requests for one | ||
| # test context share the same state. | ||
| safe_name=$(echo "${test_context}-${retry_after}-${repo_name}" | tr '/' '_' | tr -cd 'a-zA-Z0-9_-') | ||
| state_file="http-429-state-${safe_name}" | ||
| state="http-429-state-${safe_name}" | ||
|
|
||
| # Check if this is the first call (no state file exists) | ||
| if test -f "$state_file" | ||
| # This endpoint returns 429 to the first request. It forwards every later | ||
| # request to git-http-backend, so the retry succeeds. Apache can run this CGI | ||
| # for several requests at the same time. A single atomic "mkdir" selects the | ||
| # first request, because only one "mkdir" succeeds. That request returns 429 | ||
| # and leaves the directory as the "already rate-limited" marker. Every later | ||
| # "mkdir" fails, so the endpoint forwards those requests. | ||
| # | ||
| # "permanent" is the exception. It must return 429 to every request, so it | ||
| # skips the "mkdir" and records no state. A leftover directory would let a | ||
| # later "permanent" request find the marker. The endpoint would forward that | ||
| # request, which "permanent" must not allow. | ||
| if test "$retry_after" != permanent && ! mkdir "$state" 2>/dev/null | ||
| then | ||
| # Already returned 429 once, forward to git-http-backend | ||
| # Set PATH_INFO to just the repo path (without retry-after value) | ||
|
|
@@ -52,9 +62,6 @@ then | |
| exec "$GIT_EXEC_PATH/git-http-backend" | ||
| fi | ||
|
|
||
| # Mark that we've returned 429 | ||
| touch "$state_file" | ||
|
|
||
| # Output HTTP 429 response | ||
| printf "Status: 429 Too Many Requests\r\n" | ||
|
|
||
|
|
@@ -67,8 +74,7 @@ case "$retry_after" in | |
| printf "Retry-After: invalid-format-123abc\r\n" | ||
| ;; | ||
| permanent) | ||
| # Always return 429, don't set state file for success | ||
| rm -f "$state_file" | ||
| # Always return 429 | ||
| printf "Retry-After: 1\r\n" | ||
| printf "Content-Type: text/plain\r\n" | ||
| printf "\r\n" | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,96 @@ | ||
| #!/bin/sh | ||
|
|
||
| test_description='apply-one-time-script CGI helper is safe under concurrent requests' | ||
|
|
||
| . ./test-lib.sh | ||
|
|
||
| HELPER="$TEST_DIRECTORY/lib-httpd/apply-one-time-script.sh" | ||
|
|
||
| test_expect_success PIPE 'concurrent requests: one rewritten, one passed through, neither empty' ' | ||
| mkdir workdir fakebin && | ||
| ENTERED="$PWD/entered" && | ||
| GATE="$PWD/gate" && | ||
| export ENTERED GATE && | ||
| mkfifo "$ENTERED" "$GATE" && | ||
|
|
||
| # Stand in for git-http-backend. The modify role returns a response | ||
| # containing "packfile", which the one-time script rewrites. The | ||
| # passthrough role returns a response that is left untouched, but first | ||
| # announces that it has entered the helper and then blocks, so that it | ||
| # is still in flight when the modify role claims and removes the marker. | ||
| write_script fakebin/git-http-backend <<-\EOF && | ||
| printf "Status: 200 OK\r\n" | ||
| printf "Content-Type: application/x-git-result\r\n" | ||
| printf "\r\n" | ||
| if test "$ROLE" = modify | ||
| then | ||
| printf "packfile\n" | ||
| else | ||
| echo entered >"$ENTERED" | ||
| read -r released <"$GATE" | ||
| printf "refs\n" | ||
| fi | ||
| EOF | ||
|
|
||
| # The transform that replace_packfile would install as one-time-script: | ||
| # rewrite responses that contain "packfile", leave the rest alone. | ||
| write_script workdir/one-time-script <<-\EOF && | ||
| if grep packfile "$1" >/dev/null | ||
| then | ||
| sed "/packfile/q" "$1" && | ||
| printf "REPLACED\n" | ||
| else | ||
| cat "$1" | ||
| fi | ||
| EOF | ||
|
|
||
| GIT_EXEC_PATH="$PWD/fakebin" && | ||
| export GIT_EXEC_PATH && | ||
|
|
||
| # Hold GATE open read-write on fd 9 for the duration, so releasing the | ||
| # passthrough request below cannot block even if that request has | ||
| # already exited (it keeps a reader on the FIFO). | ||
| exec 9<>"$GATE" && | ||
|
|
||
| # Launch the passthrough request in the background. It enters the | ||
| # helper, signals us through ENTERED, then blocks on GATE inside the | ||
| # fake backend. The braces keep the && chain intact while backgrounding | ||
| # only the subshell, so "wait" can reap it by pid; kill it on any exit | ||
| # so a stray blocked child cannot hold the test output open and stall a | ||
| # reader such as prove. | ||
| { ( | ||
| cd workdir && | ||
| ROLE=passthrough sh "$HELPER" >../passthrough.out 2>../passthrough.err | ||
| ) & } && | ||
| passthrough_pid=$! && | ||
| test_when_finished "kill $passthrough_pid 2>/dev/null || :" && | ||
|
|
||
| # Wait until the passthrough request is past the marker check. | ||
| read -r entered <"$ENTERED" && | ||
|
|
||
| # Run the modifying request to completion while the passthrough request | ||
| # is still blocked. | ||
| ( | ||
| cd workdir && | ||
| ROLE=modify sh "$HELPER" >../modify.out 2>../modify.err | ||
| ) && | ||
|
|
||
| # Release the passthrough request and let it finish. Ignore the helper | ||
| # exit status here so a broken helper is diagnosed by the assertions | ||
| # below rather than aborting the test. | ||
| echo released >&9 && | ||
| { wait "$passthrough_pid" || :; } && | ||
|
|
||
| # Neither request may error out or produce an empty (HTTP 500) body, | ||
| # and each must have played its role: the modify request rewrote its | ||
| # response and the passthrough request came through untouched. | ||
| test_must_be_empty passthrough.err && | ||
| test_must_be_empty modify.err && | ||
| test_grep "Status: 200 OK" passthrough.out && | ||
| test_grep "Status: 200 OK" modify.out && | ||
| test_grep REPLACED modify.out && | ||
| test_grep ! REPLACED passthrough.out && | ||
| test_grep refs passthrough.out | ||
| ' | ||
|
|
||
| test_done |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Junio C Hamano wrote on the Git mailing list (how to reply to this email):
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Michael Montalbo wrote on the Git mailing list (how to reply to this email):