fix(remote): keep curl's stderr when the request fails (#850) - #853
Open
fujibee wants to merge 4 commits into
Open
fix(remote): keep curl's stderr when the request fails (#850)#853fujibee wants to merge 4 commits into
fujibee wants to merge 4 commits into
Conversation
`_remote_http_post_json` sent curl's stderr to /dev/null and reported "000" for every kind of failure alike. The caller sees only that code, so when a request failed there was nothing anywhere saying why. That cost a Windows run a long time: connect returned a bare 000, and the reason — curl could not open a path embedded in its own config file — was in the stderr this line was discarding. Removing the discard is what made the other two fixes findable at all, which is why it is worth landing on its own rather than as a detail of them. Captured to a file rather than passed straight through, and shown only when curl actually failed: on the success path `curl -sS` is already silent, and an unconditional pass-through would put curl's output in the middle of a caller's. No behaviour change on the success path, and none for any caller reading the printed HTTP code.
The stop condition was that reverting the production change leaves CI green.
It does not now: putting 2>/dev/null back turns the first test red.
tests/test_remote_curl_stderr.bats, 5 ok / 0 not ok, driving the production
_remote_http_post_json.
The helper's stderr is captured to a FILE rather than read out of bats's
$output, which merges the two streams. A test that cannot tell stdout from
stderr cannot check that a message went to the right one, and "the message
appears somewhere" is not what this fix is about -- the http code goes to
stdout and the diagnosis has to go to stderr.
failing curl the diagnosis reaches the caller's stderr
succeeding curl nothing does, AND THE STUB WROTE SOMETHING ANYWAY
both the http code is what it always was, 200 and 000
both no scratch file left in the run's own TMPDIR
control the leftover check can see a planted leftover
The second case is the one worth explaining. Real curl -sS is quiet on
success, so a stub that also stayed quiet would pass against a version that
dumped stderr unconditionally -- and that version drops noise into the middle
of a caller's output. Making the stub noisy on the success path is what tells
"shown only when curl failed" apart from "the stream was empty".
The last is the control on an absence. A glob that matches nothing looks
exactly like a glob aimed at the wrong directory, so one gets planted and the
same check has to fire. Each run gets a private TMPDIR, so "what is left
behind" is a question about this call and not about the machine.
M0 no mutation 0 red
M1 stderr discarded again 1 -- the diagnosis reaches stderr
M2 stderr shown on success too 1 -- success stays quiet
M3 the error file never removed 1 -- no scratch left behind
M4 failure reports curl's exit code 3 -- the code contract, and the two
cases that read it
NOT COVERED, and I would rather name it than let the matrix imply otherwise:
the scratch file is removed on the two normal paths, and the trap set earlier
in the function does not name it -- its list is the config and the fifo. The
signal path is therefore unmeasured here. I tried to drive one and the probe
hung on this host (the bounded copier keeps the run alive while curl is being
waited on), so what I have is a reading of the trap's text, not a measurement.
Adding curl_err to that list would be one line, but it is a change to the fix
rather than a test, so it is the author's call and not mine.
Production untouched: the diff is this one test file. Windows unverified by me.
fujibee
added a commit
that referenced
this pull request
Aug 17, 2026
tests/test_remote_curl_stderr_get.bats, 6 ok / 0 not ok, driving the production
_remote_http_get_json. Reverting the GET change turns the first test red.
A SEPARATE FILE, not a parameterised one over both helpers. These are two
functions with two shapes -- the GET side has no fifo, no copier, its own trap
and its own cleanup -- and they were fixed in two pull requests precisely so a
mutation in one cannot be covered by the other's tests. Sharing a file would
put that back: reverting the GET change would redden a case whose name says
POST.
failing curl the diagnosis reaches the caller's stderr
succeeding curl nothing does, and the stub wrote something anyway
both the http code is what it always was, 200 and 000
both no scratch file left in the run's own TMPDIR
control the leftover check can see a planted leftover
shape the request still carries the team header, so the cases
above are reporting on a GET and not on something reshaped
M0 no mutation 0 red
M1 stderr discarded again 1 -- the diagnosis reaches stderr
M2 stderr shown on success too 1 -- success stays quiet
M3 the error file never removed 1 -- no scratch left behind
M4 failure reports curl's exit code 3 -- the code contract and the two
cases that read it
THE MUTATIONS ARE CONFINED TO THE GET FUNCTION'S LINE RANGE, re-derived from
the file each round. On this branch the POST helper carries the same lines
verbatim, so a file-wide substitution would have mutated #853's fix and
reported the result under #854.
And the first run of that matrix was wrong in a way worth recording. The perl
replacements contained $curl_err and $curl_status unescaped, so perl expanded
them as its own variables -- to nothing. M2 became "test an empty string" and
M4 became "assign an empty code", neither of which is the mutation named. Both
still produced red, and the red looked plausible: M2 reddened the failure case
instead of the success case, which is the only reason I looked. A mutation that
does something other than what its label says is a false entry in this table,
not a stricter one.
NOT COVERED, and named rather than left to the matrix to imply: the scratch
file is removed on the two normal paths, and the trap set earlier in the
function does not name it. The signal path is unmeasured -- a reading of the
trap's text, not a measurement. Adding curl_err to that list is one line, but
it changes the fix rather than testing it, so it is the author's call.
Production untouched: the diff is this one test file. Windows unverified by me.
… its files (#850) Review asked for one line: put curl_err in the existing trap so an early exit cannot strand it. I wrote that line, added a test for it, and the test failed. WHAT THE MEASUREMENT SAID. An EXIT trap set inside a function runs after that function's frame is gone. A single-quoted trap body therefore expands $cfg in the CALLER's scope, where no local of that name exists -- so it removes the empty string, returns 0, and reads as a cleanup that worked. bash 3.2.57 TRAP SEES: [EMPTY] bash 5.3.15 TRAP SEES: [EMPTY] So this was never about curl_err. The pre-existing trap has never swept anything on an early exit. Driven with a stub curl that fails after writing its headers, and a cat that fails so errexit leaves the function between the mktemp and the tail cleanup, a real run left all three behind: agmsg-curl-cfg.cn24V2 a 0600 config naming the request body agmsg-curl-err.cJJHwm agmsg-header-pipe.2u0Xbp The config is the one that matters. It is the file this helper exists to keep out of curl's argv. THE FIX. Bake the paths into the trap at set time with printf %q instead of expanding them when it fires, and create curl_err with the other temporaries so it exists before the trap that must remove it. Same run afterwards: nothing left. This is more than the one line review asked for, and the reason is that the one line does not work. Reverting to it would leave a test failing and the leak open. If the author prefers a different shape -- non-local variables, or re-arming the trap after each mktemp -- the tests here bind the behaviour, not the mechanism. TESTS. tests/test_remote_curl_stderr.bats, 7 ok / 0 not ok. failing curl the diagnosis reaches the caller's stderr succeeding curl nothing does, and the stub wrote to stderr anyway both the http code is unchanged, 200 and 000 both no scratch file left in the run's own TMPDIR early exit errexit out of the middle sweeps ALL THREE files premise an EXIT trap cannot read the locals of the function that set it -- measured, so that the printf %q baking is not read as ceremony and simplified back control the leftover check fires on a planted leftover The early-exit case took three attempts and each failure is worth naming: 1 A signal. The probe hung: on that path the bounded copier is never reaped, so it outlives the shell holding the inherited stdout, and bats's capture waits on it. Review suggested errexit instead, which is bounded. 2 Writing the fake `cat` through the sandbox symlink. `>` follows a symlink, so it wrote to the system's own /bin/cat -- refused by the OS here. On a machine where it was not refused, the test would have replaced a system binary. The symlink is removed first now. 3 A failure stub that exited without writing the headers, which strands the copier on open() and hangs the run for the same reason as (1). The stub now writes headers and then fails, which is also what a real curl does when it dies on the body. Existing suite unchanged: tests/test_remote.bats 122 ok / 0 not ok. Windows unverified by me.
fujibee
added a commit
that referenced
this pull request
Aug 18, 2026
tests/test_remote_curl_stderr_get.bats, 6 ok / 0 not ok, driving the production
_remote_http_get_json. Reverting the GET change turns the first test red.
A SEPARATE FILE, not a parameterised one over both helpers. These are two
functions with two shapes -- the GET side has no fifo, no copier, its own trap
and its own cleanup -- and they were fixed in two pull requests precisely so a
mutation in one cannot be covered by the other's tests. Sharing a file would
put that back: reverting the GET change would redden a case whose name says
POST.
failing curl the diagnosis reaches the caller's stderr
succeeding curl nothing does, and the stub wrote something anyway
both the http code is what it always was, 200 and 000
both no scratch file left in the run's own TMPDIR
control the leftover check can see a planted leftover
shape the request still carries the team header, so the cases
above are reporting on a GET and not on something reshaped
M0 no mutation 0 red
M1 stderr discarded again 1 -- the diagnosis reaches stderr
M2 stderr shown on success too 1 -- success stays quiet
M3 the error file never removed 1 -- no scratch left behind
M4 failure reports curl's exit code 3 -- the code contract and the two
cases that read it
THE MUTATIONS ARE CONFINED TO THE GET FUNCTION'S LINE RANGE, re-derived from
the file each round. On this branch the POST helper carries the same lines
verbatim, so a file-wide substitution would have mutated #853's fix and
reported the result under #854.
And the first run of that matrix was wrong in a way worth recording. The perl
replacements contained $curl_err and $curl_status unescaped, so perl expanded
them as its own variables -- to nothing. M2 became "test an empty string" and
M4 became "assign an empty code", neither of which is the mutation named. Both
still produced red, and the red looked plausible: M2 reddened the failure case
instead of the success case, which is the only reason I looked. A mutation that
does something other than what its label says is a false entry in this table,
not a stricter one.
NOT COVERED, and named rather than left to the matrix to imply: the scratch
file is removed on the two normal paths, and the trap set earlier in the
function does not name it. The signal path is unmeasured -- a reading of the
trap's text, not a measurement. Adding curl_err to that list is one line, but
it changes the fix rather than testing it, so it is the author's call.
Production untouched: the diff is this one test file. Windows unverified by me.
Review's remaining point: whatever is created BEFORE the trap is armed is unprotected, and the previous shape kept that window -- it made three things and then armed a trap over all three. A failure at the second or third leaves the first stranded, including a 0600 config naming the request body. The window cannot be closed by reordering, because there is always a first allocation. So there is now only one: work_dir="$(mktemp -d ...)" trap "rm -rf <baked work_dir>" EXIT INT TERM cfg="$work_dir/config"; curl_err="$work_dir/stderr"; header_fifo="$work_dir/header" Everything else is made inside a directory that is already condemned, the normal path removes the same directory, and cleanup is one rm -rf on a path this function minted. The printf %q baking stays for the reason measured earlier: an EXIT trap runs after the function's frame is gone and cannot expand its locals. New case: make mkfifo fail, which happens after the directory exists and after the config is inside it, and assert nothing survives. AND THE ABSENCE ASSERTIONS WERE AIMED AT NAMES THAT NO LONGER EXIST. The leftover checks globbed agmsg-curl-cfg.* and agmsg-curl-err.*, which this layout never creates. They would have passed on any behaviour at all, and gone on passing while the new directory leaked. Both they and their planted-leftover control now use the name the helper really mints. An absence assertion pointed at a name nothing uses is indistinguishable from a clean run -- and I wrote four of them without noticing the rename underneath. 8 ok / 0 not ok tests/test_remote_curl_stderr.bats 122 ok / 0 not ok tests/test_remote.bats, unchanged Separately, and NOT part of this change: on the failure path the bounded copier is not reaped when the function leaves early, so it can outlive the shell holding inherited descriptors. That predates the stderr work and is filed on its own rather than folded in here. Windows unverified by me.
fujibee
added a commit
that referenced
this pull request
Aug 18, 2026
tests/test_remote_curl_stderr_get.bats, 6 ok / 0 not ok, driving the production
_remote_http_get_json. Reverting the GET change turns the first test red.
A SEPARATE FILE, not a parameterised one over both helpers. These are two
functions with two shapes -- the GET side has no fifo, no copier, its own trap
and its own cleanup -- and they were fixed in two pull requests precisely so a
mutation in one cannot be covered by the other's tests. Sharing a file would
put that back: reverting the GET change would redden a case whose name says
POST.
failing curl the diagnosis reaches the caller's stderr
succeeding curl nothing does, and the stub wrote something anyway
both the http code is what it always was, 200 and 000
both no scratch file left in the run's own TMPDIR
control the leftover check can see a planted leftover
shape the request still carries the team header, so the cases
above are reporting on a GET and not on something reshaped
M0 no mutation 0 red
M1 stderr discarded again 1 -- the diagnosis reaches stderr
M2 stderr shown on success too 1 -- success stays quiet
M3 the error file never removed 1 -- no scratch left behind
M4 failure reports curl's exit code 3 -- the code contract and the two
cases that read it
THE MUTATIONS ARE CONFINED TO THE GET FUNCTION'S LINE RANGE, re-derived from
the file each round. On this branch the POST helper carries the same lines
verbatim, so a file-wide substitution would have mutated #853's fix and
reported the result under #854.
And the first run of that matrix was wrong in a way worth recording. The perl
replacements contained $curl_err and $curl_status unescaped, so perl expanded
them as its own variables -- to nothing. M2 became "test an empty string" and
M4 became "assign an empty code", neither of which is the mutation named. Both
still produced red, and the red looked plausible: M2 reddened the failure case
instead of the success case, which is the only reason I looked. A mutation that
does something other than what its label says is a false entry in this table,
not a stricter one.
NOT COVERED, and named rather than left to the matrix to imply: the scratch
file is removed on the two normal paths, and the trap set earlier in the
function does not name it. The signal path is unmeasured -- a reading of the
trap's text, not a measurement. Adding curl_err to that list is one line, but
it changes the fix rather than testing it, so it is the author's call.
Production untouched: the diff is this one test file. Windows unverified by me.
fujibee
added a commit
that referenced
this pull request
Aug 18, 2026
…tream (#850) CI caught this; my machine did not. Two cases went red on macOS CI at the head I had reported as green: not ok 351 an untranslated POSIX path reaching native curl is the reported 000 not ok 352 a Windows path reaching a POSIX curl is equally a 000 Both assert the helper returns "000". Both got it. What they compared it against was bats's $output, which MERGES stdout and stderr, and under load the runner produced: remote.sh: line 318: .../agmsg-header-pipe.JVbrl7/header: Interrupted system call 000 An exact comparison against a two-line string fails. The code was right; the instrument was reading a stream with something else on it. Not a platform difference -- same OS, same code, different timing. The fifo open in the copier was interrupted on a busy runner and bash reported it. My local runs never hit it, so the assertion looked exact when it was only usually-exact. The helper's stderr now goes to a file and $output holds the http code alone, which is the same separation the stderr tests (#853) make deliberately. There the separation IS the subject; here it was an accident I had not noticed depending on. 8 ok / 0 not ok locally, and the matrix is unchanged: M0 no mutation 0 red M1 renderer back to passthrough 3 -- DATA, DUMP-HEADER, completes M2 cygpath -m becomes -w 4 -- those three and the slash assertion M3 header field untranslated 2 -- DUMP-HEADER and completes M4 data field untranslated 2 -- DATA and completes Production untouched. Windows unverified by me.
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.
Part 3 of 3 splitting the Windows connect fix. All three together are on
win-connect-fix-850, which is the branch to install for a real walk.This is the one that made the other two findable. Landing it separately is the point: without it the next failure in this family is another opaque
000.What is wrong
_remote_http_post_jsonsent curl's stderr to/dev/nulland reported000for every kind of failure alike. The caller sees only that code, so when a request failed there was nothing anywhere saying why.That cost the Windows run a long time:
connectreturned a bare000, and the reason — curl could not open a path embedded in its own config file — was in the stderr this line was discarding.Captured to a file rather than passed straight through, and shown only when curl actually failed: on the success path
curl -sSis already silent, and an unconditional pass-through would put curl's output in the middle of a caller's.Scope, stated because it is narrower than the reasoning
This fixes the POST helper only, which is what the verified Windows commit changed.
_remote_http_get_jsonstill carries2>/dev/null, and the same argument applies to it —pullgoes through the GET path, so a failure there is still an opaque000with the reason discarded. It embeds no paths in its config, so PR-A's defect does not reach it, but the diagnosability one does.Left out deliberately rather than folded in: this PR carries what was measured on the Windows machine, and extending it here would mix verified work with unverified. Worth a follow-up.
What was verified on Windows 11 / Git Bash
Quoted rather than summarised:
What was verified on macOS, and what that is worth
tests/test_remote.bats— 122 ok / 0 not ok, on this branch alone.Green was not evidence anything was diagnosed. When this was written, no test in the suite asserted on curl's stderr — that is no longer true, and the tests added since are described at the end of this body. What green establishes is that capturing it, and emitting it only on failure, leaves every existing caller's behaviour and every reported HTTP code unchanged.
Not measured
A team with an existing store has not been migrated on Windows. The verified run was a fresh team.
Tests, added at
adbf2b34d74ftests/test_remote_curl_stderr.bats— 5 ok / 0 not ok, driving the production_remote_http_post_json. Production is untouched; the diff since the reviewed head is this one file.The helper's stderr is captured to a file rather than read from bats's
$output, which merges the streams. The http code goes to stdout and the diagnosis has to go to stderr — a test that cannot tell them apart cannot check that.The second row is the one that needs explaining. Real
curl -sSis quiet on success, so a stub that also stayed quiet would pass against a version that dumped stderr unconditionally — and that version drops noise into the middle of a caller's output. Making the stub noisy on success is what separates "shown only when curl failed" from "the stream happened to be empty".The last row is the control on an absence: a glob that matches nothing looks exactly like a glob aimed at the wrong directory.
2>/dev/nullrestoredNot covered
The signal path. The scratch file is removed on both normal paths, and the trap set earlier in the function does not name it — its list is the config and the fifo. That is a reading of the trap's text, not a measurement: I tried to drive a signal through it and the probe hung on this host, because the bounded copier keeps the run alive while curl is being waited on.
Adding
curl_errto that trap is one line, but it changes the fix rather than testing it, so it is the author's call rather than mine.Windows is unverified by me, here as elsewhere in this series.
Tests and one further production change, at
d59284c11e38Review asked for one line — put
curl_errin the existing trap so an early exit cannot strand it. I wrote that line, added a test for it, and the test failed. What the measurement then said is why this head carries more than tests.An EXIT trap set inside a function cannot read that function's locals
It runs after the frame is gone, so a single-quoted body expands
$cfgin the caller's scope, where no local of that name exists. It removes the empty string, returns 0, and reads as a cleanup that worked.So this was never about
curl_err. The pre-existing trap has never swept anything on an early exit. Driven with a stub curl that fails after writing its headers and acatthat fails, so errexit leaves the function between the mktemp and the tail cleanup, a real run left all three behind:The config is the one that matters — it is the file this helper exists to keep out of curl's argv.
The fix: bake the paths into the trap with
printf %qat set time instead of expanding them when it fires, and createcurl_errwith the other temporaries so it exists before the trap that must remove it. Same run afterwards leaves nothing. This is more than the one line asked for, and the reason is that the one line does not work; the tests bind the behaviour, not the mechanism, so a different shape is open to the author.tests/test_remote_curl_stderr.bats— 7 ok / 0 not okprintf %qbaking is not mistaken for ceremony and simplified back2>/dev/nullrestoredExisting suite unchanged on this head:
tests/test_remote.bats— 122 ok / 0 not ok.The early-exit case took three attempts, and each failure is worth naming
catthrough the sandbox symlink.>follows a symlink, so it wrote to the system's own/bin/cat— refused by the OS here. On a machine where it was not refused, the test would have replaced a system binary. The symlink is removed first now.open()and hangs for the same reason as (1). The stub now writes headers and then fails, which is what a real curl does when it dies on the body.Windows is unverified by me.
Update at
a38d6f8e1430— one directory, armed before anything else existsReview's remaining point: whatever is created before the trap is armed is unprotected, and the previous shape kept that window — it made three things and then armed a trap over all three. A failure at the second or third strands the first, including a 0600 config naming the request body.
Reordering cannot close it, because there is always a first allocation. So there is now exactly one:
Everything else is made inside a directory that is already condemned; the normal path removes the same directory. The
printf %qbaking stays, for the measured reason above.New case: make
mkfifofail — after the directory exists and after the config is inside it — and assert nothing survives.And four of my absence assertions were aimed at names that no longer exist
The leftover checks globbed
agmsg-curl-cfg.*andagmsg-curl-err.*. This layout never creates those. They would have passed on any behaviour, and gone on passing while the new directory leaked. Both they and their planted-leftover control now use the name the helper really mints.An absence assertion pointed at a name nothing uses is indistinguishable from a clean run — and I wrote four of them without noticing the rename I had just made underneath.
Filed separately, not folded in
On the failure path the bounded copier is not reaped when the function leaves early, and it holds the caller's inherited stdout — which is what hung three of my probes. It predates the stderr work, so it is #864 rather than a change here.