t/lib-httpd: make CGI test helpers concurrency-safe#2171
Conversation
|
There is an issue in commit f8d8372:
|
|
There is an issue in commit e46f718:
|
|
There is an issue in commit ac4d384:
|
ac4d384 to
f4e5366
Compare
|
There is an issue in commit 56503c0:
|
|
There is an issue in commit 517865e:
|
|
There is an issue in commit f4e5366:
|
f4e5366 to
234a4c5
Compare
|
There is an issue in commit b8239cb:
|
|
There is an issue in commit 2953b76:
|
|
There is an issue in commit 234a4c5:
|
4dbc6a1 to
364dcc5
Compare
apply-one-time-script.sh checks for the "one-time-script" marker, runs it, captures the git-http-backend response in the fixed-name files "out" and "out_modified", and removes the marker only after it has finished serving the modified response. Because the client receives the response body before that removal, it can start its next request while the marker still exists. Apache can then run this CGI for two requests at once: a partial fetch that receives a REF_DELTA against a missing promisor object lazily fetches that base while the first response is still in flight. The second request passes the marker check, the first request then removes the marker, and the second fails to exec the now-missing marker, emits no output, and the server answers HTTP 500: fatal: ... The requested URL returned error: 500 fatal: could not fetch <oid> from promisor remote This has been seen as a flaky failure of t5616.47 on the macOS CI runners. Claim the marker atomically with a rename, and only once the one-time script has succeeded and actually changed the response; give the scratch files per-request names. A request that loses the rename, or whose script fails or leaves the response unchanged, serves the unmodified body and keeps the marker for a later request. No path emits an empty body, so the HTTP 500 no longer occurs. Add t5567 to lock this down. The overlap depends on timing, so a live httpd test such as t5616.47 (the real code path) passes almost every time even against the buggy helper; t5567 instead drives the helper directly with a fake git-http-backend and forces the overlap with FIFOs. Against the pre-fix helper it fails with the same shell error seen in the field: ./one-time-script: No such file or directory Signed-off-by: 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. Decide whether this is the first request with a single atomic mkdir, which fails if the directory already exists, so exactly one of any concurrent requests is rate-limited and the rest are forwarded. There is no accompanying regression test. The check and the set are adjacent commands with no external step in between to synchronize on, so the overlap cannot be forced deterministically, only reproduced probabilistically; the fix is preventive. Signed-off-by: Michael Montalbo <mmontalbo@gmail.com>
The apply-one-time-script.sh and http-429.sh fixes addressed the same underlying problem: a test helper assuming it has exclusive access to a file when the web server can run it for several requests at once. The atomic idioms that avoid this are not specific to CGI or to HTTP, so document them generally, alongside the other guidance for writing tests, and leave a pointer from the lib-httpd helper list rather than a local comment. The note covers the anti-pattern (a "test -f" then a separate act) and the two safe operations (mkdir to elect a winner, rename to consume a one-shot marker), citing Git's own lockfile machinery and make_symlink() as precedent. Signed-off-by: Michael Montalbo <mmontalbo@gmail.com>
364dcc5 to
771d264
Compare
|
/preview |
|
Preview email sent as pull.2171.git.1783479090.gitgitgadget@gmail.com |
|
/submit |
|
Submitted as pull.2171.git.1783479584.gitgitgadget@gmail.com To fetch this version into To fetch this version to local tag |
| @@ -6,21 +6,31 @@ | |||
| # | |||
There was a problem hiding this comment.
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>
>
> apply-one-time-script.sh checks for the "one-time-script" marker, runs
> it, captures the git-http-backend response in the fixed-name files "out"
> and "out_modified", and removes the marker only after it has finished
> serving the modified response. Because the client receives the response
> body before that removal, it can start its next request while the marker
> still exists. Apache can then run this CGI for two requests at once: a
> partial fetch that receives a REF_DELTA against a missing promisor
> object lazily fetches that base while the first response is still in
> flight. The second request passes the marker check, the first request
> then removes the marker, and the second fails to exec the now-missing
> marker, emits no output, and the server answers HTTP 500:
>
> fatal: ... The requested URL returned error: 500
> fatal: could not fetch <oid> from promisor remote
>
> This has been seen as a flaky failure of t5616.47 on the macOS CI
> runners.
Thanks for this detailed write-up. The analysis looks good.
> Claim the marker atomically with a rename, and only once the one-time
> script has succeeded and actually changed the response; give the scratch
> files per-request names. A request that loses the rename, or whose
> script fails or leaves the response unchanged, serves the unmodified
> body and keeps the marker for a later request. No path emits an empty
> body, so the HTTP 500 no longer occurs.
Hmph.
> +#
> +# 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.
> +
> +test -f one-time-script || exec "$GIT_EXEC_PATH/git-http-backend"
>
> - "$GIT_EXEC_PATH/git-http-backend" >out
> - ./one-time-script out >out_modified
> +LC_ALL=C
> +export LC_ALL
The original was somehow inconsistent in that it forced C locale
only when one-time-script munged the output, and otherwise the
backend was run in the original locale. I am not sure if that
matters very much.
> +out=out.$$
> +modified=out-modified.$$
> +"$GIT_EXEC_PATH/git-http-backend" >"$out"
> +
> +if ./one-time-script "$out" 2>/dev/null >"$modified" &&
> + ! cmp -s "$out" "$modified" &&
> + mv one-time-script one-time-script.$$ 2>/dev/null
> +then
> + cat "$modified"
> else
> + cat "$out"
> fi
We may run the one-time script, find that it modified the payload,
and then another instance of us may start running before we can move
the one-time script away, so the second request can see "ah,
one-time-script is there, nobody has claimed it by renaming" and run
it again, no? So this solution may shrink the race window but may
not completely eliminate it, unless we have some coordination among
ourselves, perhaps?
Ah, we assume running one-time-script itself multiple times is safe
and does not cause issues. Our objective is to avoid returning
modified output twice. So while the first instance of us
successfully renames one-time-script to one-time-script.$$ and emits
the modified result, even if the second instance raced and managed
to run the script again, it will fail to rename with "mv", and
discard the modified output, and instead show the unmodified output
generated by the backend.
OK. It is a bit tricky. It may help future readers if we said
something about this in the proposed log message (i.e., we consider
that it is perfectly fine to run one-time-script more than once; we
only want to avoid letting the second invocation's output used).
Thanks.| @@ -26,14 +26,17 @@ repo_path="${remaining#*/}" # Get rest (repo path) | |||
| # The repo name is the first component before any "/" | |||
There was a problem hiding this comment.
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"
> -| @@ -854,6 +854,38 @@ from the test harness library. At the end of the script, call | |||
| 'test_done'. | |||
There was a problem hiding this comment.
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>
>
> The apply-one-time-script.sh and http-429.sh fixes addressed the same
> underlying problem: a test helper assuming it has exclusive access to a
> file when the web server can run it for several requests at once. The
> atomic idioms that avoid this are not specific to CGI or to HTTP, so
> document them generally, alongside the other guidance for writing tests,
> and leave a pointer from the lib-httpd helper list rather than a local
> comment. The note covers the anti-pattern (a "test -f" then a separate
> act) and the two safe operations (mkdir to elect a winner, rename to
> consume a one-shot marker), citing Git's own lockfile machinery and
> make_symlink() as precedent.
>
> Signed-off-by: Michael Montalbo <mmontalbo@gmail.com>
> ---
> t/README | 32 ++++++++++++++++++++++++++++++++
> t/lib-httpd.sh | 3 +++
> 2 files changed, 35 insertions(+)
Thanks for a nice finishing touch.
> diff --git a/t/README b/t/README
> index 085921be4b..a9d425f392 100644
> --- a/t/README
> +++ b/t/README
> @@ -854,6 +854,38 @@ from the test harness library. At the end of the script, call
> 'test_done'.
>
>
> +Writing concurrency-safe helpers
> +--------------------------------
> +
> +Some test code runs concurrently: a test may background work with '&',
> +and the helper scripts installed for the web server (in t/lib-httpd) are
> +run once per request, so the same script can execute for several
> +requests at once. Such code cannot assume it has exclusive access to a
> +file.
> +
> +When exactly one of several concurrent processes needs to "win" a
> +decision, a single atomic filesystem operation can make it, rather than
> +a check followed by a separate action. A "test -f X" then "touch X"
> +(or "rm X") races: two processes can both pass the check before either
> +acts. Two atomic operations avoid this:
> +
> + - "mkdir dir", which fails if the directory already exists, so that
> + exactly one caller wins, electing a first or only request (see
> + t/lib-httpd/http-429.sh).
> +
> + - "mv src dst" (rename), which fails if the source is gone, so that
> + exactly one caller consumes it, claiming a planted one-shot marker
> + (see t/lib-httpd/apply-one-time-script.sh).
> +
> +A "$$" suffix on per-request scratch files keeps concurrent invocations
> +from clobbering each other's fixed-name files.
> +
> +This is a standard shell locking idiom, and the same reasoning behind
> +Git's own lockfile machinery, which creates its lock with O_CREAT|O_EXCL,
> +and make_symlink() in t/test-lib.sh, which uses an mkdir lock: an atomic
> +operation whose failure indicates that another process got there first.
> +
> +
> Test harness library
> --------------------
>
> diff --git a/t/lib-httpd.sh b/t/lib-httpd.sh
> index fc646447d5..d64f9c8c2d 100644
> --- a/t/lib-httpd.sh
> +++ b/t/lib-httpd.sh
> @@ -159,6 +159,9 @@ prepare_httpd() {
> mkdir -p "$HTTPD_DOCUMENT_ROOT_PATH"
> cp "$TEST_PATH"/passwd "$HTTPD_ROOT_PATH"
> cp "$TEST_PATH"/proxy-passwd "$HTTPD_ROOT_PATH"
> + # The web server can run any of these CGI scripts for two requests at
> + # once; a helper that keeps state between requests must do so with an
> + # atomic operation. See "Writing concurrency-safe helpers" in t/README.
> install_script incomplete-length-upload-pack-v2-http.sh
> install_script incomplete-body-upload-pack-v2-http.sh
> install_script error-no-report.sh| @@ -26,14 +26,17 @@ repo_path="${remaining#*/}" # Get rest (repo path) | |||
| # The repo name is the first component before any "/" | |||
There was a problem hiding this comment.
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.
The httpd tests share a handful of CGI helper scripts under t/lib-httpd.
Two of them keep state between requests in the shared HTTPD_ROOT_PATH on
the assumption that the web server hands them one request at a time. It
does not: Apache serves requests concurrently, and a single Git operation
can open more than one request to the same endpoint at once. A partial
fetch that receives a REF_DELTA against a missing promisor object lazily
fetches that base while the first response is still being served.
Under that overlap apply-one-time-script.sh loses: two requests both pass
its "test -f one-time-script" check, one removes the marker, and the other
fails to exec it and emits an empty body, which the server answers as
HTTP 500. In the field this is an occasional failure[1] of:
t5616.47 tolerate server sending REF_DELTA against missing promisor objects
on the macOS CI runners, with:
fatal: ... The requested URL returned error: 500
fatal: could not fetch from promisor remote
I could not reproduce it against a live server (the window is tiny and
timing-dependent), but the macOS CI error log names the exact failure, and
the new test reproduces the helper's shell error.
http-429.sh keeps its "already returned 429 once" state with the same
non-atomic test-and-set. Its retry flow is mostly sequential so it seems
less likely to fail, but it is the same latent race.
Each fix is local: claim/consume the one-shot marker with an atomic rename,
and elect the first request with an atomic mkdir, rather than a "test -f"
followed by a separate remove or touch.
t5567, which drives the helper directly with no web server so the
overlap can be forced deterministically.
not specific to CGI or HTTP), citing Git's own lockfile machinery
and make_symlink(), with a pointer from the lib-httpd list.
[1] https://github.com/gitgitgadget/git/actions/runs/28756172690/job/85263916762?pr=2169