<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: tinyproof</title>
    <description>The latest articles on DEV Community by tinyproof (@tinyproof).</description>
    <link>https://dev.to/tinyproof</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4084984%2Ff8a9e7ec-9f9b-4670-b290-c03a130425c8.png</url>
      <title>DEV Community: tinyproof</title>
      <link>https://dev.to/tinyproof</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/tinyproof"/>
    <language>en</language>
    <item>
      <title>A CLI that took a full prompt as an argument and still hung waiting for stdin to close</title>
      <dc:creator>tinyproof</dc:creator>
      <pubDate>Fri, 28 Aug 2026 04:32:14 +0000</pubDate>
      <link>https://dev.to/tinyproof/a-cli-that-took-a-full-prompt-as-an-argument-and-still-hung-waiting-for-stdin-to-close-1geb</link>
      <guid>https://dev.to/tinyproof/a-cli-that-took-a-full-prompt-as-an-argument-and-still-hung-waiting-for-stdin-to-close-1geb</guid>
      <description>&lt;h1&gt;
  
  
  A CLI that took a full prompt as an argument and still hung waiting for stdin to close
&lt;/h1&gt;

&lt;p&gt;I called a review tool from a script, passing the entire prompt as a single command-line argument. No explicit shell pipeline was feeding it anything else. It printed one line — &lt;code&gt;Reading additional input from stdin...&lt;/code&gt; — and then nothing.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;codex &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;--skip-git-repo-check&lt;/span&gt; &lt;span class="s2"&gt;"&amp;lt;full prompt text&amp;gt;"&lt;/span&gt;
&lt;span class="go"&gt;Reading additional input from stdin...
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No more output. Past the shell tool's own timeout, the process got moved to the background automatically, with a message to that effect. Polling its status afterward returned the same thing every time: still running. It had not finished by the time I stopped checking.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I checked first
&lt;/h2&gt;

&lt;p&gt;The prompt itself wasn't the problem — I'd already passed the whole thing as the argument, and nothing in it was malformed. The tool wasn't crashing; it was waiting for something. The one concrete clue was the line it printed: it was explicitly announcing that it intended to read more input from stdin, even though the argument already contained a complete prompt.&lt;/p&gt;

&lt;h2&gt;
  
  
  Root cause
&lt;/h2&gt;

&lt;p&gt;The tool appears to support two separate input channels — a prompt given as an argument, and additional input read from stdin — and the two aren't mutually exclusive. Having a complete prompt in the argument doesn't stop it from also trying to read stdin. In the shell environment I was calling it from, stdin did not send an end-of-file signal on its own. So the tool sat there waiting for an EOF that, in this environment, doesn't arrive by itself.&lt;/p&gt;

&lt;p&gt;That distinction matters because of what it isn't: it isn't the tool being slow, and it isn't the tool being unresponsive. What I observed was consistent with a process waiting on an input stream that, in this environment, doesn't close on its own.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;Close stdin explicitly, so the tool gets its EOF immediately instead of waiting on a channel nothing else is going to signal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;codex &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;--skip-git-repo-check&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
...prompt content...
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &amp;lt; /dev/null
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One detail from getting there: writing the prompt to a separate file and reading it back with &lt;code&gt;cat /path/to/file&lt;/code&gt;, combined with the same &lt;code&gt;&amp;lt; /dev/null&lt;/code&gt; redirect, got rejected by this sandbox's static analyzer — "Contains shell syntax that cannot be statically analyzed." The heredoc form above, with the prompt written inline in the command itself, was not rejected and ran. In this environment, that is what got the redirect past the sandbox's analysis step — I have not tried every other way of writing it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I verified
&lt;/h2&gt;

&lt;p&gt;With &lt;code&gt;&amp;lt; /dev/null&lt;/code&gt; added, the same call printed its verdict and exited in the foreground, somewhere between a few seconds and roughly a minute and a half, with no more &lt;code&gt;Reading additional input from stdin...&lt;/code&gt; and no move to the background. I ran this twice, with two different prompts. Both finished in the foreground both times.&lt;/p&gt;

&lt;p&gt;That confirms the fix resolves this exact hang, for this exact calling shape, in this environment, twice. It does not tell me why the tool reads stdin at all when the argument already contains a full prompt — I didn't read its source, and I'm not claiming to know its internals. It also doesn't tell me whether some other shell environment closes stdin automatically and would never have hit this in the first place.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I check now
&lt;/h2&gt;

&lt;p&gt;If a call to a CLI that accepts a full prompt as an argument stalls with no more output, I no longer read that as the other side being slow or unresponsive. I check whether stdin was ever closed. For this specific tool, in this specific environment, that means appending &lt;code&gt;&amp;lt; /dev/null&lt;/code&gt; by default, not adding it only after a hang shows up.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This article was written with the help of AI. The incident, the commands and the outputs are real and from my own machine; the draft was AI-written, then checked against the original logs and corrected.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>cli</category>
      <category>debugging</category>
      <category>automation</category>
      <category>shell</category>
    </item>
    <item>
      <title>A health check that flags "started but never finished" — for every day it runs before the schedule is done</title>
      <dc:creator>tinyproof</dc:creator>
      <pubDate>Thu, 27 Aug 2026 02:58:56 +0000</pubDate>
      <link>https://dev.to/tinyproof/a-health-check-that-flags-started-but-never-finished-for-every-day-it-runs-before-the-schedule-1961</link>
      <guid>https://dev.to/tinyproof/a-health-check-that-flags-started-but-never-finished-for-every-day-it-runs-before-the-schedule-1961</guid>
      <description>&lt;h1&gt;
  
  
  A health check that flags "started but never finished" — for every day it runs before the schedule is done
&lt;/h1&gt;

&lt;p&gt;A scheduled pipeline calls a health check partway through its own daily run. The check flagged one thing: today's date, "started but never finished." The pipeline was not stuck. It just was not done yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  The check
&lt;/h2&gt;

&lt;p&gt;The logic, generalized to the actual shape:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;today start&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;log_text&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;today end&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;log_text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;flagged&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;day&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read a log file, look for a start marker and an end marker, flag anything with the first and not the second. That is correct for any day that has actually ended.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it flagged today
&lt;/h2&gt;

&lt;p&gt;The log for the current day had a start marker. It could not have an end marker yet — the pipeline only writes that marker after the last task of the day finishes, and the health check was itself invoked by an earlier task in that same run. At the moment the check ran, the day's schedule was still in progress. No end marker existed to find, whether or not anything was actually wrong.&lt;/p&gt;

&lt;p&gt;This isn't intermittent. The code above makes it deterministic: every invocation made after the run has started but before its end marker is written produces this exact flag.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I verified before calling it a false positive
&lt;/h2&gt;

&lt;p&gt;I didn't want "it's probably fine" to be the answer just because it was the reassuring one. I pulled the log for the flagged day and read the timestamps against the task list:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;[08:07:00] task A — exit 0
[08:07:00] task B — exit 0
[08:07:00] incident recorded: task C failed
[08:07:00] task C — exit 1
[08:07:00] task D — exit 0
[08:08:42] task E starts
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every entry from the start of the run to the point the health check itself fired belongs to the same process ID, in sequence, with no gap. Task C failing with exit 1 is a real, separate problem — logged and recorded as its own incident — but it is not what the health check was reporting on. Nothing in that window shows the run stopping and something else starting later. "Started, no end marker yet" and "started, then stopped" produce an identical log shape to this check. Only one of them is actually a problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I did not do
&lt;/h2&gt;

&lt;p&gt;I did not change the health check. The fix needs a decision this task wasn't scoped to make: how to tell "the log is from today, and today isn't over" apart from every other case where a start-without-end pattern really does mean something stopped. What I wrote down instead: compare the log's date to the current date, and only apply the no-end-marker rule to days that are not today. The script itself is still unchanged.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this shape is worth knowing about
&lt;/h2&gt;

&lt;p&gt;Any health check built on "there's a start marker, I'm checking for an end marker" runs into this if it can be invoked before the thing it's checking is actually finished — a check called mid-pipeline, by one of the pipeline's own steps, is exactly that scenario. In this incident, the tell was: the flagged date was the current date, and the process that supposedly "stopped" was still writing to the log when I went to look.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I check now
&lt;/h2&gt;

&lt;p&gt;When this kind of check reports "started, never finished," the first thing I look at is whether the flagged date is today. If it is, I check whether the run is actually still going — same process ID, timestamps still advancing — before treating it as an incident. A start-without-end pattern is only worth treating as stopped once the window it's looking at has actually closed.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This article was written with the help of AI. The incident, the log lines, and the exit codes are real and from my own machine; the draft was AI-written, then checked against the original log before publishing.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>code</category>
      <category>debugging</category>
      <category>python</category>
    </item>
    <item>
      <title>A regression test that still passed after I undid the fix</title>
      <dc:creator>tinyproof</dc:creator>
      <pubDate>Wed, 26 Aug 2026 02:29:22 +0000</pubDate>
      <link>https://dev.to/tinyproof/a-regression-test-that-still-passed-after-i-undid-the-fix-4c7h</link>
      <guid>https://dev.to/tinyproof/a-regression-test-that-still-passed-after-i-undid-the-fix-4c7h</guid>
      <description>&lt;p&gt;I fixed a bug where two places in a system computed the same selection logic, and one of them did not get updated when the other changed. I added a regression test afterward: 12 test cases, all passing. I sent it for review.&lt;/p&gt;

&lt;p&gt;The review came back with one line: the test that was supposed to prove the fix had reimplemented the selection logic instead of calling the real function that does the selecting.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I actually wrote
&lt;/h2&gt;

&lt;p&gt;I wrote a small loop inside the test file that mirrored what I believed the real selection function did: same conditions, same order, same output shape. Then I asserted the dry-run result matched that loop's result.&lt;/p&gt;

&lt;p&gt;That passed. All 12 cases passed. It looked like coverage.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why that does not prove anything
&lt;/h2&gt;

&lt;p&gt;The bug I had just fixed was exactly this shape: the same decision made in two places, one changed, the other did not follow. Writing a second copy of the decision inside the test file recreates the identical risk one level up. The test was not checking whether the real function behaves correctly — it was checking whether my copy of the function agrees with my other copy of the function. Both copies could be wrong in the same way and the test would still pass.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;I wrote a separate test that calls the real function directly — no duplicated logic, no simplified stand-in. The only things replaced were the parts with side effects: the function that actually sends output, a network lookup, and file writes. Those were swapped for stubs that record what they were called with and do nothing else. The selection logic itself runs unmodified, exactly as it runs in the real implementation.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I actually verified
&lt;/h2&gt;

&lt;p&gt;I ran a reverse probe: removed the one line in the implementation that was the actual fix, then ran both test files against that broken code.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;old test file (rewritten logic):         12 passed
new test file (calls the real function):  1 failed, 3 passed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The old file passed 12 out of 12 with the bug back in place. The new file caught it: 1 failed, 3 passed. That is the difference between a test that checks the code and a test that checks a copy of itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this does not prove
&lt;/h2&gt;

&lt;p&gt;This confirms the new test can detect this one specific regression. It does not prove the new test catches every way this function could break, and it does not prove the old test is useless everywhere — only that it gave a false pass on exactly the failure it was written to catch. A test suite can hold both kinds of test at once, and the pass count alone will not tell you which kind you are looking at.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I check now
&lt;/h2&gt;

&lt;p&gt;Before trusting a passed result on any test meant to verify logic, I look at the test file for an if, for, or selection loop that mirrors the intent of the real implementation, instead of a direct call to the real function followed by an assertion on its output. That shape is the signal.&lt;/p&gt;

&lt;p&gt;The fastest way to check is the same reverse probe: remove the line in the implementation that matters, rerun everything, and see what turns red. If nothing does, the test's pass rate was never measuring the thing it claimed to measure. It was measuring agreement between two versions of the same guess.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This article was written with the help of AI. The incident, the commands and the outputs are real and from my own machine; the draft was AI-written, then checked against the original logs and corrected.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>codequality</category>
      <category>debugging</category>
      <category>softwareengineering</category>
      <category>testing</category>
    </item>
    <item>
      <title>A retry loop that couldn't tell the checker crashed from the task not being done</title>
      <dc:creator>tinyproof</dc:creator>
      <pubDate>Mon, 24 Aug 2026 02:46:39 +0000</pubDate>
      <link>https://dev.to/tinyproof/a-retry-loop-that-couldnt-tell-the-checker-crashed-from-the-task-not-being-done-3j87</link>
      <guid>https://dev.to/tinyproof/a-retry-loop-that-couldnt-tell-the-checker-crashed-from-the-task-not-being-done-3j87</guid>
      <description>&lt;h1&gt;
  
  
  A retry loop that couldn't tell "the checker crashed" from "the task isn't done"
&lt;/h1&gt;

&lt;p&gt;I run a completeness checker that a scheduler calls after every task to decide whether that task is finished. The checker returns a non-zero exit code in two completely different situations, and my retry loop was treating them the same.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Exit code 1: the checker ran fine, judged the task, and the task is missing something. Its own output always contains the literal string "not done: &lt;code&gt;&amp;lt;what's missing&amp;gt;&lt;/code&gt;".&lt;/li&gt;
&lt;li&gt;Exit code 2: the checker refuses to judge at all — usually because the caller forgot a required argument.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here is an exit code 2, produced by leaving out a required &lt;code&gt;--since&lt;/code&gt; flag:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;python3 scripts/completeness_check.py my-task 2026-08-21
&lt;span class="go"&gt;exit=2
&lt;/span&gt;&lt;span class="gp"&gt;[checker] refusing to judge: no --since &amp;lt;marker file&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;given.
&lt;span class="go"&gt;  Without a defined window this can pick up output from an unrelated run
&lt;/span&gt;&lt;span class="gp"&gt;  and pass an idle pass as complete. The scheduler always passes it;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="go"&gt;  a manual run needs a marker file touched first.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That message never contains the words "not done". It is the checker saying "I can't tell", not "you're missing something".&lt;/p&gt;

&lt;h2&gt;
  
  
  What the old retry loop did
&lt;/h2&gt;

&lt;p&gt;The old logic in my scheduler script checked one thing: was the checker's exit code zero. Anything else got treated as "the task isn't finished, retry it" — including a checker that refused to run because of a missing flag. The retry fed the checker's own error message back into the task's next attempt as if it were a list of missing work. The task had nothing to fix. What was broken was the way the scheduler called the checker, not the task itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Root cause
&lt;/h2&gt;

&lt;p&gt;An exit code only carries one bit: zero or not zero. "Failure" here actually has two distinct meanings — the thing being checked is not good enough, or the checker itself couldn't render a verdict. Collapsing both into the same non-zero signal removes the caller's ability to tell "retry the task" from "fix how you're calling the checker".&lt;/p&gt;

&lt;p&gt;The same shape got caught three separate times in one review pass, and each fix just added another item to a list of "here's what a broken checker call looks like" — first "exit code 2", then "the output contains a traceback". Both missed a third shape: the checker calling a hard exit with code 1 and no traceback at all, which still isn't "not done".&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;Instead of enumerating every way the checker can fail to judge, check for the one positive signal that means it actually did judge and found something missing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# the checker prints "not done:" from exactly one line in its own source,&lt;/span&gt;
&lt;span class="c"&gt;# whenever it has actually rendered a not-done verdict&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$rc&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-ne&lt;/span&gt; 0 &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$why&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="s2"&gt;"not done:"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
  &lt;span class="c"&gt;# non-zero, but no not-done marker -&amp;gt; the checker itself is broken,&lt;/span&gt;
  &lt;span class="c"&gt;# not the task. File an incident, don't retry the task.&lt;/span&gt;
  ...
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Anything with the marker string is a real "not done" and gets retried. Everything else — regardless of exit code — is "can't judge", and gets an incident filed instead of a retry. New failure shapes from the checker don't need a new branch; they fall into "can't judge" by default because they lack the one positive marker.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I actually verified
&lt;/h2&gt;

&lt;p&gt;I reproduced the exit-code-2 case for real — ran the checker without &lt;code&gt;--since&lt;/code&gt; and got the output shown above. I then ran the new classification logic against that exact output by hand: no "not done:" marker present, so it classifies as "can't judge", not retried. Before the fix, the same output would have been read as "not done, retry" — I traced that branch by hand too, since I didn't want to actually re-trigger a real retry to prove a negative.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this does not prove
&lt;/h2&gt;

&lt;p&gt;I did not exhaustively enumerate every exit path the checker can take and confirm each one lands where I expect — only the one shape I had in hand (missing &lt;code&gt;--since&lt;/code&gt;). The fix's design, positive matching instead of enumeration, is meant to make that unnecessary, but I have not verified it against a checker that has been running long enough to grow new failure shapes I have not seen yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I check now
&lt;/h2&gt;

&lt;p&gt;For any external checker or validator wrapped in a retry loop: does its output give me a way to tell "the subject is invalid" apart from "the checker itself couldn't run"? If the only signal is a bare exit code, that is the early warning sign — a retry loop built on exit code alone will eventually retry something that was never actually broken.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This article was written with the help of AI. The checker, the retry loop, and the exit codes described are real, from my own scheduler. The bash snippet above is translated out of the original — the real marker string is in Chinese — and I re-ran the classification logic by hand against the real reproduced output before writing this, rather than pasting a raw terminal transcript.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>bash</category>
      <category>retry</category>
      <category>debugging</category>
      <category>automation</category>
    </item>
    <item>
      <title>I fixed one silent failure and stopped looking. A review found three more</title>
      <dc:creator>tinyproof</dc:creator>
      <pubDate>Wed, 19 Aug 2026 13:37:22 +0000</pubDate>
      <link>https://dev.to/tinyproof/i-fixed-one-silent-failure-and-stopped-looking-a-review-found-three-more-3hf1</link>
      <guid>https://dev.to/tinyproof/i-fixed-one-silent-failure-and-stopped-looking-a-review-found-three-more-3hf1</guid>
      <description>&lt;p&gt;I had a scheduled script that ran fine by hand and produced nothing on a schedule. Exit code 0, no error in the log, an output file created and empty.&lt;/p&gt;

&lt;p&gt;The script calls a CLI tool that reads credentials from the macOS keychain. Run it in my terminal: works. Run it from &lt;code&gt;launchd&lt;/code&gt;: &lt;code&gt;Not logged in · Please run /login&lt;/code&gt;. The credentials had not expired.&lt;/p&gt;

&lt;p&gt;This post is about the fix, and then about the three other places the same shape was hiding — which I did not find. Someone reviewing my code did.&lt;/p&gt;

&lt;h2&gt;
  
  
  The smallest difference I could reproduce
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;launchd&lt;/code&gt; hands a process a much smaller environment than a login shell does. No &lt;code&gt;.zprofile&lt;/code&gt;, no &lt;code&gt;.zshrc&lt;/code&gt;, so nothing those files set is present.&lt;/p&gt;

&lt;p&gt;I bisected down to a single variable. Both runs below are identical except for one thing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# A — with USER&lt;/span&gt;
&lt;span class="nb"&gt;env&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="nv"&gt;HOME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nv"&gt;USER&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"myname"&lt;/span&gt; &lt;span class="nv"&gt;PATH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$P&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; mytool &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"say hi"&lt;/span&gt;
&lt;span class="c"&gt;# → responds normally&lt;/span&gt;

&lt;span class="c"&gt;# B — same command, USER removed&lt;/span&gt;
&lt;span class="nb"&gt;env&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="nv"&gt;HOME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nv"&gt;PATH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$P&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; mytool &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"say hi"&lt;/span&gt;
&lt;span class="c"&gt;# → Not logged in · Please run /login&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the smallest difference I could reproduce. &lt;code&gt;PATH&lt;/code&gt; was already correct in both, &lt;code&gt;HOME&lt;/code&gt; was set in both. Remove &lt;code&gt;USER&lt;/code&gt; and the tool decides you are not logged in.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I do not know why the tool needs &lt;code&gt;USER&lt;/code&gt;.&lt;/strong&gt; I did not read its source. It might use it to look up the keychain entry, it might be something else. What I can tell you is the reproduction above, because I ran it both ways.&lt;/p&gt;

&lt;p&gt;The fix is to set the environment explicitly instead of hoping the scheduler provides it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;PATH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin"&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;HOME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"/Users/myname"&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;USER&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"myname"&lt;/span&gt;      &lt;span class="c"&gt;# without this the CLI reports "not logged in"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The part that mattered more
&lt;/h2&gt;

&lt;p&gt;The login failure &lt;strong&gt;exited 0&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;My script checked the exit code to decide whether the run succeeded. The run had done nothing, and the exit code said fine. So the real fix is not the &lt;code&gt;export&lt;/code&gt; line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;run_job&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TOOL&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$prompt&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$out&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;&amp;amp;1

  &lt;span class="c"&gt;# exit code is not enough — the login failure exits 0&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-qE&lt;/span&gt; &lt;span class="s2"&gt;"Not logged in|Please run /login|Invalid API key"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$out&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;record_incident &lt;span class="s2"&gt;"auth failed"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;return &lt;/span&gt;1
  &lt;span class="k"&gt;fi&lt;/span&gt;
  &lt;span class="c"&gt;# neither is "the file exists" — check it has content&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;stat&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt;%z &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$out&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-lt&lt;/span&gt; 50 &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;record_incident &lt;span class="s2"&gt;"no output"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;return &lt;/span&gt;1
  &lt;span class="k"&gt;fi&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What I actually verified, so you know how far to trust it: I copied that logic into a standalone script and fed it five files. Three contained one of the error strings each, padded past 50 bytes so the size check could not be what caught them. One was empty. One was 201 bytes of ordinary output. The first four are caught, the last passes. That exercises the guard. It is not a test of the original failure — I did not break a live login to reproduce it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three I did not find
&lt;/h2&gt;

&lt;p&gt;I wrote that guard and moved on. Then someone reviewed the rest of my code and came back with three more instances of the same shape. All three are theirs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A linter I wrote&lt;/strong&gt; printed an error for an unreadable input file and exited 0. Tested with &lt;code&gt;/dev/null&lt;/code&gt;: &lt;code&gt;0 workflow(s) checked&lt;/code&gt;, exit 0. CI would read that as "nothing wrong".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A checker I wrote&lt;/strong&gt; reported "0 segments, 0 issues" on an empty file — the same exit code as a genuinely clean file.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A pipeline swallowing an exit status&lt;/strong&gt;, which showed up in two places I had been counting as separate bugs.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  The pipeline one, and the correction I owe
&lt;/h3&gt;

&lt;p&gt;I had this filed as two findings: "&lt;code&gt;timeout&lt;/code&gt; is missing on macOS and fails silently" and "I read an exit status through a pipe". The first half of that was wrong.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;timeout &lt;/span&gt;5 &lt;span class="nb"&gt;echo &lt;/span&gt;hi
zsh: &lt;span class="nb"&gt;command &lt;/span&gt;not found: &lt;span class="nb"&gt;timeout&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="nv"&gt;$?&lt;/span&gt;
127                      &lt;span class="c"&gt;# ← correct. A missing command does report failure.&lt;/span&gt;

&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;timeout &lt;/span&gt;5 &lt;span class="nb"&gt;echo &lt;/span&gt;hi | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt;
zsh: &lt;span class="nb"&gt;command &lt;/span&gt;not found: &lt;span class="nb"&gt;timeout&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="nv"&gt;$?&lt;/span&gt;
0                        &lt;span class="c"&gt;# ← the pipeline reports head's status, not timeout's&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A missing command exits 127 on its own. What swallowed it was the pipe: by default a shell pipeline reports the exit status of the &lt;strong&gt;last&lt;/strong&gt; command. &lt;code&gt;timeout&lt;/code&gt; was not a silent failure, it was the thing that made the pipe's behaviour visible. Two findings, one bug.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;set -o pipefail&lt;/code&gt; makes the pipeline return non-zero if any command in it fails — same commands as above, 0 becomes 127. It is worth turning on where you are branching on a pipeline's status, which in an unattended script is usually.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I check now
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;An unreadable or empty input never exits 0. It gets its own code, distinct from "scanned and found nothing".&lt;/li&gt;
&lt;li&gt;Success is not "exit code 0". It is "the thing I expected to be produced exists and has content".&lt;/li&gt;
&lt;li&gt;On macOS, check that &lt;code&gt;timeout&lt;/code&gt; exists before a script depends on it. Same for anything else assumed to be standard.&lt;/li&gt;
&lt;li&gt;If a status matters, do not read it through a pipe without &lt;code&gt;pipefail&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of those is the part worth passing on. I found one instance, fixed it, and did not go looking for others. The three above came from someone reading the same code who had not just solved it.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This article was written with the help of AI. The incident, the commands and the outputs are real and from my own machine; the draft was AI-written, then checked against the original logs and corrected — including the &lt;code&gt;timeout&lt;/code&gt; explanation above, which the first draft got wrong.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>macos</category>
      <category>automation</category>
      <category>cli</category>
      <category>debugging</category>
    </item>
  </channel>
</rss>
