<?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: Onur Kesim</title>
    <description>The latest articles on DEV Community by Onur Kesim (@onurkesim).</description>
    <link>https://dev.to/onurkesim</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%2F4061239%2F101f4c2c-efe0-4761-ba05-3774cd409467.jpg</url>
      <title>DEV Community: Onur Kesim</title>
      <link>https://dev.to/onurkesim</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/onurkesim"/>
    <language>en</language>
    <item>
      <title>One Letter Broke Four Toolchains. A Symlink Didn't Fix It.</title>
      <dc:creator>Onur Kesim</dc:creator>
      <pubDate>Tue, 18 Aug 2026 08:51:41 +0000</pubDate>
      <link>https://dev.to/onurkesim/one-letter-broke-four-toolchains-a-symlink-didnt-fix-it-3290</link>
      <guid>https://dev.to/onurkesim/one-letter-broke-four-toolchains-a-symlink-didnt-fix-it-3290</guid>
      <description>&lt;h2&gt;
  
  
  Three Sessions, Four Broken Toolchains
&lt;/h2&gt;

&lt;p&gt;When configuring an automated coding assistant or build workflow, one of the most effective safety constraints you can set is simple: "If you see something unexpected, stop and ask."&lt;/p&gt;

&lt;p&gt;On July 26, 2026, while working on a Flutter + .NET project, that guardrail triggered across three separate build sessions. Three times the automated assistant halted execution, flagging what appeared to be four completely unrelated failures across different tools:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;dart run build_runner build&lt;/code&gt; failed with &lt;code&gt;package_config.json did not contain its own root package&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;flutter analyze&lt;/code&gt; broke down due to a Language Server Protocol (LSP) JSON-RPC framing crash.&lt;/li&gt;
&lt;li&gt;The Android Gradle Plugin (AGP) threw a path validation check error.&lt;/li&gt;
&lt;li&gt;A supporting &lt;code&gt;.ps1&lt;/code&gt; script failed to execute due to path literal encoding corruption.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Three work sessions, four distinct toolchains, four different error messages, and a separate search rabbit hole for each. Yet none of the error logs pointed to the actual common denominator. While I didn't log the exact SDK patch versions at the moment of failure, the root cause was a single uppercase non-ASCII character—an &lt;code&gt;Ö&lt;/code&gt;—in the project's parent directory path.&lt;/p&gt;

&lt;p&gt;The LSP framing failure in &lt;code&gt;flutter analyze&lt;/code&gt; is a prime example of how deep these path leaks go. The official &lt;a href="https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/" rel="noopener noreferrer"&gt;Language Server Protocol specification&lt;/a&gt; explicitly defines the header field: "The length of the content part in bytes."&lt;/p&gt;

&lt;p&gt;When a directory path containing multi-byte UTF-8 characters (like &lt;code&gt;Ö&lt;/code&gt;) is injected into protocol payloads, the character count no longer matches the total byte length. The behavior observed—a sudden connection collapse during analysis—is consistent with framing misalignment when character count deviates from raw byte length. The path wasn't just an external string on disk; it was actively leaking into low-level protocol frames.&lt;/p&gt;

&lt;h2&gt;
  
  
  It Wasn't the Space. I Tested.
&lt;/h2&gt;

&lt;p&gt;The immediate reaction to path failures on Windows is almost universally: "There must be a space in the directory name." It is a well-worn assumption in software engineering, but an assumption is a hypothesis, not a diagnosis. Removing spaces and non-ASCII characters simultaneously proves nothing about which change fixed the issue. If you alter two variables at once, a passing build won't tell you which one was breaking your toolchain.&lt;/p&gt;

&lt;p&gt;To determine whether spaces were actually responsible, I ran a single-variable isolation test on the exact same project layout, executing &lt;code&gt;flutter build apk&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Pure ASCII path with spaces: &lt;code&gt;EXIT = 0&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Identical path with an added &lt;code&gt;Ö&lt;/code&gt;: &lt;code&gt;EXIT = 255&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One character changed. Everything else stayed identical.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;C:\Dev\Proje\App Test\       -&amp;gt; EXIT = 0   (Build Passed)
C:\Dev\ProjeÖ\App Test\      -&amp;gt; EXIT = 255 (Build Failed)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This simple test highlighted a crucial debugging principle: "It's probably spaces" is a guess until you isolate the variables and measure them independently.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Junction That Lied
&lt;/h2&gt;

&lt;p&gt;Once the non-ASCII character was identified as the culprit, the standard workaround on Windows was the obvious next step: leave the physical files where they were, create a directory junction (a Windows reparse point similar to a symlink) from a clean ASCII path like &lt;code&gt;C:\project&lt;/code&gt; to the actual directory, and execute the builds through the junction.&lt;/p&gt;

&lt;p&gt;When tested against the Dart and Flutter toolchains, it worked. Commands executed cleanly through the alias.&lt;/p&gt;

&lt;p&gt;Then I ran the Android build pipeline, and AGP crashed immediately.&lt;/p&gt;

&lt;p&gt;The strangest part was the error output itself. AGP did not report &lt;code&gt;C:\project&lt;/code&gt;—the junction path passed into the build invocation. Instead, it printed the underlying physical target path containing the &lt;code&gt;Ö&lt;/code&gt;. The junction was meant to hide the non-ASCII character, but AGP exposed it anyway.&lt;/p&gt;

&lt;p&gt;The behavior points directly to how Java handles canonical path resolution. Java's &lt;code&gt;File&lt;/code&gt; documentation describes canonical-path resolution as following symbolic links on UNIX platforms, though it doesn't explicitly detail Windows reparse points. I didn't audit AGP's internal source code, but the behavior I measured was unambiguous: AGP printed the physical target path instead of the junction path.&lt;/p&gt;

&lt;p&gt;To verify whether this behavior was tied to the Java build environment, I ran a counter-test using the exact same junction setup:&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;flutter build web
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Executing &lt;code&gt;flutter build web&lt;/code&gt; through the exact same junction, targeting the exact same physical directory, returned &lt;code&gt;EXIT = 0&lt;/code&gt;. The web pipeline doesn't go through Gradle at all, so whatever the JVM does with reparse points never enters the picture — that's the difference I could point to, though I didn't instrument it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;         [ C:\project (ASCII Junction) ]
                        |
            +-----------+-----------+
            |                       |
     (Dart / Web)                (JVM / AGP)
            |                       |
    Bypasses Reparse        Reads Canonical Path
            |                       |
            v                       v
      Passes (EXIT 0)       Exposes 'Ö' (EXIT 255)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The hypothesis held from both directions: the toolchain running on the JVM broke, while the non-JVM toolchain passed.&lt;/p&gt;

&lt;p&gt;This highlights a broader rule: you cannot assume an abstraction layer—whether a junction, a symlink, a bind mount, or a container volume mapping—will be seen identically by every tool in your stack. An abstraction is only transparent to tools that refrain from querying what lies behind it. When a tool explicitly inspects canonical paths, it bypasses the abstraction entirely.&lt;/p&gt;

&lt;h2&gt;
  
  
  There Was an Official Escape Hatch. I Didn't Use It.
&lt;/h2&gt;

&lt;p&gt;At this stage, AGP ships an official override flag designed specifically to bypass this path check in &lt;code&gt;gradle.properties&lt;/code&gt;. Online discussions routinely recommend enabling this override flag as the fast track to getting builds working again.&lt;/p&gt;

&lt;p&gt;I chose not to use it, opting instead to relocate the entire repository to a clean ASCII root directory. That decision rested on three specific technical arguments:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Silencing a check does not remove the underlying risk.&lt;/strong&gt; AGP's path validation check wasn't added arbitrarily. Build components like &lt;code&gt;aapt2&lt;/code&gt; have documented histories of failing on non-ASCII paths, such as &lt;a href="https://github.com/dotnet/android/issues/6925" rel="noopener noreferrer"&gt;dotnet/android#6925&lt;/a&gt; in the .NET Android toolchain, where path handling issues trigger unhelpful &lt;code&gt;APT2000&lt;/code&gt; errors. Overriding the check doesn't fix downstream tools; it simply defers the failure into an unflagged error later in the pipeline.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The flag pollutes the shared repository configuration.&lt;/strong&gt; The junction was an external, local setup on a single workstation. &lt;code&gt;gradle.properties&lt;/code&gt;, however, is committed to version control. Adding a path override flag would mean embedding a workaround for a local environment constraint into the project's permanent configuration, leaving a flag in the repo forever with no obvious context for future maintainers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pattern recognition.&lt;/strong&gt; When a single root cause breaks four toolchains across three separate sessions, stacking local workarounds only guarantees more surprises down the road.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;An escape flag silences a tool when it reports potential instability. Before setting it, the critical question to answer is: Is the reported risk invalid in my environment, or am I just ignoring useful information? If you cannot prove the risk is invalid, you aren't suppressing a false positive—you are suppressing data.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reproduce It in Five Minutes
&lt;/h2&gt;

&lt;p&gt;Because the failure is tied to the path string rather than to project state, you can reproduce the chain on your own machine — I measured this on one Windows setup, so treat the exit codes as mine, not as a guarantee:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Create a fresh project using &lt;code&gt;flutter create demo&lt;/code&gt; inside a clean ASCII directory path. Run &lt;code&gt;flutter build apk&lt;/code&gt; to confirm it returns &lt;code&gt;EXIT = 0&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Move the project directory to a path that contains spaces, but remains strictly pure ASCII (e.g., &lt;code&gt;C:\Dev\Proje\App Test\demo&lt;/code&gt;). Run &lt;code&gt;flutter build apk&lt;/code&gt; to confirm it still returns &lt;code&gt;EXIT = 0&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Add a single non-ASCII character to the parent folder name (e.g., &lt;code&gt;C:\Dev\ProjeÖ\App Test\demo&lt;/code&gt;). Run &lt;code&gt;flutter analyze&lt;/code&gt; and observe the LSP and build behaviors.&lt;/li&gt;
&lt;li&gt;Set up a Windows Junction pointing to that directory from a clean ASCII alias (&lt;code&gt;C:\project&lt;/code&gt;). Execute a native Dart command versus an Android Gradle build to observe how JVM path resolution exposes the physical directory.&lt;/li&gt;
&lt;li&gt;Run &lt;code&gt;flutter build web&lt;/code&gt; through the same junction to observe the non-JVM build path succeeding.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you decide to relocate a project to a clean ASCII path on your own machine, remember to wipe local build caches before re-testing.&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;flutter clean
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Tools like Dart and Gradle store absolute path strings inside local cache folders like &lt;code&gt;.dart_tool/&lt;/code&gt; and &lt;code&gt;build/&lt;/code&gt;. Running &lt;code&gt;flutter clean&lt;/code&gt; ensures those caches are wiped, preventing old path references from causing false build failures after a move.&lt;/p&gt;

&lt;p&gt;It is also good practice to verify a clean working tree before performing any major directory migration:&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;git status --porcelain
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ensuring the git status is completely clean beforehand rules out uncommitted changes as a cause of any issues encountered after the move.&lt;/p&gt;

</description>
      <category>flutter</category>
      <category>android</category>
      <category>debugging</category>
      <category>windows</category>
    </item>
    <item>
      <title>Your Test Went Red. Can You Read It?</title>
      <dc:creator>Onur Kesim</dc:creator>
      <pubDate>Tue, 11 Aug 2026 09:06:16 +0000</pubDate>
      <link>https://dev.to/onurkesim/your-test-went-red-can-you-read-it-4mm4</link>
      <guid>https://dev.to/onurkesim/your-test-went-red-can-you-read-it-4mm4</guid>
      <description>&lt;p&gt;&lt;strong&gt;Thesis:&lt;/strong&gt; A gate being green is not enough. When it turns red, it must be &lt;strong&gt;readable&lt;/strong&gt;—and the guard that makes it readable must stand &lt;strong&gt;where it cannot disappear&lt;/strong&gt;.&lt;/p&gt;




&lt;p&gt;To test my assertion, I deleted &lt;code&gt;SKIP LOCKED&lt;/code&gt; from an outbox claiming query in my side project's sync layer, leaving a plain &lt;code&gt;FOR UPDATE&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The test turned red in about six seconds, throwing an exception with a distinct Postgres error code: &lt;code&gt;55P03&lt;/code&gt; (&lt;code&gt;lock_not_available&lt;/code&gt;). The gate bit me immediately. But it only bit because of a single decision I had made earlier: setting &lt;code&gt;-c lock_timeout=5000&lt;/code&gt; directly in the database connection string.&lt;/p&gt;

&lt;p&gt;My repository code (&lt;code&gt;OutboxClaimStore.cs&lt;/code&gt;) had a client timeout of 10 seconds configured (&lt;code&gt;CommandTimeout = 10&lt;/code&gt;; Npgsql's default is 30). Had &lt;code&gt;lock_timeout&lt;/code&gt; been missing, removing &lt;code&gt;SKIP LOCKED&lt;/code&gt; wouldn't have hung the runner forever, but it would have hit that 10-second client cutoff. Npgsql would not have handed me a lock error either. On command timeout it sends a real Postgres &lt;code&gt;CancelRequest&lt;/code&gt; on a side connection; the server aborts the statement with &lt;code&gt;57014 query_canceled&lt;/code&gt; — and Npgsql then throws that &lt;code&gt;PostgresException&lt;/code&gt; away, replacing it with &lt;code&gt;NpgsqlException: Exception while reading from stream&lt;/code&gt;, inner &lt;code&gt;TimeoutException: Timeout during reading attempt&lt;/code&gt;. Even the code it &lt;em&gt;did&lt;/em&gt; get would only have said "somebody cancelled me," never "I was waiting for a row lock."&lt;/p&gt;

&lt;p&gt;By enforcing &lt;code&gt;lock_timeout&lt;/code&gt; at the database level, Postgres explicitly told me &lt;em&gt;why&lt;/em&gt; it stopped: SQLSTATE &lt;code&gt;55P03&lt;/code&gt; (&lt;code&gt;lock_not_available&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;This is the story of that setting and why where you put your guards determines whether you actually have them.&lt;/p&gt;

&lt;p&gt;Unless stated otherwise, every timing in this article comes from a single scripted run against PostgreSQL 16.13 (Ubuntu) on 5 August 2026. Configuration values such as &lt;code&gt;CommandTimeout&lt;/code&gt; and &lt;code&gt;lock_timeout&lt;/code&gt; are quoted from the source file rather than measured, and the mutant-8 result comes from a separate &lt;code&gt;dotnet test&lt;/code&gt; run the same day, whose runtime versions I did not record.&lt;/p&gt;




&lt;h2&gt;
  
  
  §1 — The Same Session Proves Nothing
&lt;/h2&gt;

&lt;p&gt;If two dispatcher instances share a single database session, testing a &lt;code&gt;SKIP LOCKED&lt;/code&gt; clause measures nothing.&lt;/p&gt;

&lt;p&gt;In my outbox implementation, &lt;code&gt;OutboxClaimStore&lt;/code&gt; opens a separate &lt;code&gt;NpgsqlConnection&lt;/code&gt; per call — and because the test holds a third connection open across the pump, the pool is forced to hand out a genuinely different backend session. The comment in &lt;code&gt;OutboxClaimStore.cs&lt;/code&gt; records why the dispatcher never shares a session:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;"a session always 'sees' its own uncommitted locks"&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;To demonstrate this, I ran experiment M1, executing a lease-based &lt;code&gt;UPDATE&lt;/code&gt; query twice in a row within a single session &lt;em&gt;(reassembled from the repro script's output)&lt;/em&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;           run           |            claimed
-------------------------+-------------------------------
 A1  SKIP LOCKED present | 1,2,3,4,5,6,7,8,9,10
 A2  SKIP LOCKED present | 11,12,13,14,15,16,17,18,19,20
 B1  SKIP LOCKED REMOVED | 1,2,3,4,5,6,7,8,9,10
 B2  SKIP LOCKED REMOVED | 11,12,13,14,15,16,17,18,19,20
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;A1&lt;/code&gt; matched &lt;code&gt;B1&lt;/code&gt;, and &lt;code&gt;A2&lt;/code&gt; matched &lt;code&gt;B2&lt;/code&gt;. Deleting &lt;code&gt;SKIP LOCKED&lt;/code&gt; inside a single session changed absolutely nothing.&lt;/p&gt;

&lt;p&gt;The mechanism here is worth clarifying: the reason sequential calls in a single session return distinct items isn't strictly that the session sees its own locks. Rather, the claim query bumps &lt;code&gt;attempts&lt;/code&gt; and — more to the point — pushes &lt;code&gt;available_at&lt;/code&gt; into the future. The candidate filter is &lt;code&gt;available_at &amp;lt;= @now&lt;/code&gt;, so it is that second write, not the lock, that removes the row from the next query's candidate pool.&lt;/p&gt;

&lt;p&gt;If you test your outbox claiming queries inside a single session, your test suite will remain green even if concurrent locking is completely broken.&lt;/p&gt;




&lt;h2&gt;
  
  
  §2 — An Anonymous Timeout Is Not an Error
&lt;/h2&gt;

&lt;p&gt;Removing &lt;code&gt;SKIP LOCKED&lt;/code&gt; breaks concurrency logic, and your test should fail. But &lt;em&gt;how&lt;/em&gt; it fails dictates whether you can diagnose the issue.&lt;/p&gt;

&lt;p&gt;Without a configured database timeout, Postgres waits for a locked row until the holding transaction ends. The test hangs until an external shell script terminates it (all runs below: PostgreSQL 16.13, measured 5 August 2026):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;M3 exit_code=124  (124 = killed by the ceiling)  wall_ms=8014
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Exit code &lt;code&gt;124&lt;/code&gt; came from the Linux shell &lt;code&gt;timeout&lt;/code&gt; command, not Npgsql or Postgres. The database emitted no logs and no SQLSTATE. The value &lt;strong&gt;8,014 ms is not an execution measurement&lt;/strong&gt;—it is an artificial ceiling enforced by the shell script.&lt;/p&gt;

&lt;p&gt;Relying on a client-side timeout (&lt;code&gt;CommandTimeout = 10&lt;/code&gt;; Npgsql defaults to 30) cuts the wait at ten seconds, but what surfaces is a stream-read timeout, not a lock error.&lt;/p&gt;

&lt;p&gt;With &lt;code&gt;lock_timeout&lt;/code&gt; set to 5 s for that session in test run M4, the failure mode changes entirely:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ERROR:  canceling statement due to lock timeout
M4 exit_code=1  elapsed_ms=5044
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A separate run (M6) with VERBOSE error reporting confirms the code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ERROR:  55P03: canceling statement due to lock timeout
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Comparing these runs highlights the key differences:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;M2 vs M3:&lt;/strong&gt; With &lt;code&gt;SKIP LOCKED&lt;/code&gt; present (M2), the whole &lt;code&gt;psql&lt;/code&gt; invocation — process start, connect, query, exit — took &lt;strong&gt;42 ms&lt;/strong&gt; in that run. Without it (M3), the query blocked until the shell ceiling cut it off at &lt;strong&gt;8,014 ms&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;M3 vs M4:&lt;/strong&gt; In M3, the shell script killed the process silently at 8 seconds. In M4, with a session-level &lt;code&gt;lock_timeout&lt;/code&gt; of 5 s, Postgres aborted the statement at &lt;strong&gt;5,044 ms&lt;/strong&gt; and raised &lt;code&gt;55P03&lt;/code&gt;. M7, below, is the run that separates a session &lt;code&gt;SET&lt;/code&gt; from a startup option.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In my mutation-test log for &lt;code&gt;mutant-8&lt;/code&gt; (where &lt;code&gt;SKIP LOCKED&lt;/code&gt; was dropped, leaving a plain &lt;code&gt;FOR UPDATE&lt;/code&gt;), Npgsql threw &lt;code&gt;55P03&lt;/code&gt; out of &lt;code&gt;OutboxClaimStore.ClaimAsync&lt;/code&gt;; &lt;code&gt;PumpOnceAsync&lt;/code&gt; propagated it instead of swallowing it, so the test died on a named exception at the first pump — six seconds in, with the 120-second hang detector never firing.&lt;/p&gt;

&lt;p&gt;Two things worth pinning down:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Row order matters:&lt;/strong&gt; Lock contention surfaces when locked rows sit at the head of the &lt;code&gt;ORDER BY&lt;/code&gt; clause. If enough unlocked candidate rows precede the locked ones to satisfy the query's &lt;code&gt;LIMIT&lt;/code&gt;, Postgres fills the limit and returns without blocking.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Why the setting belongs in the connection string:&lt;/strong&gt; with &lt;code&gt;-c lock_timeout=5000&lt;/code&gt;, a worker opening a connection with that string aborts within 5 seconds of hitting row contention instead of holding its connection open indefinitely. That is a real trade-off, not a free win — if your workload would rather wait than fail, scope the guard to your test data source.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A fair question at this point: why not &lt;code&gt;FOR UPDATE NOWAIT&lt;/code&gt;? I did not measure it — the repro script never runs it — so everything I say about it here is definitional rather than measured: &lt;code&gt;NOWAIT&lt;/code&gt; reports the same &lt;code&gt;55P03&lt;/code&gt; condition without waiting at all, and needs no GUC and no placement rule to get wrong, while &lt;code&gt;lock_timeout&lt;/code&gt; bounds how long the wait may last. Whether that bound is worth having depends on a consumer I did not test here. What I did measure is that one setting produced the readable failure.&lt;/p&gt;




&lt;h2&gt;
  
  
  §3 — The Guard That Vanishes in Silence
&lt;/h2&gt;

&lt;p&gt;The critical question is not just whether to set &lt;code&gt;lock_timeout&lt;/code&gt;, but &lt;em&gt;where&lt;/em&gt; to set it.&lt;/p&gt;

&lt;p&gt;If you configure &lt;code&gt;lock_timeout&lt;/code&gt; inside your application using a session-level statement (&lt;code&gt;SET lock_timeout = '5s'&lt;/code&gt;), you are placing your guard on mutable session state. A pooled connection is typically handed back after a reset such as &lt;code&gt;DISCARD ALL&lt;/code&gt; or &lt;code&gt;RESET ALL&lt;/code&gt;, so the next check-out starts clean.&lt;/p&gt;

&lt;p&gt;In Postgres, &lt;code&gt;RESET ALL&lt;/code&gt; restores every session GUC to its &lt;em&gt;default value&lt;/em&gt;, and the docs define that precisely: "the value that the parameter would have had, if no &lt;code&gt;SET&lt;/code&gt; had ever been issued for it in the current session." The sources it can come from are listed explicitly — a compiled-in default, &lt;code&gt;postgresql.conf&lt;/code&gt;, &lt;strong&gt;command-line options&lt;/strong&gt;, or per-database/per-user settings. That list is the whole trick: a &lt;code&gt;SET&lt;/code&gt; is not on it, and a startup option is.&lt;/p&gt;

&lt;p&gt;In Postgres, a &lt;code&gt;lock_timeout&lt;/code&gt; value of &lt;code&gt;0&lt;/code&gt; does not mean zero milliseconds. &lt;strong&gt;It means disabled (wait indefinitely).&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;When you pass &lt;code&gt;-c lock_timeout=5000&lt;/code&gt; as a connection string option (Npgsql writes the startup packet itself and puts this in the same &lt;code&gt;options&lt;/code&gt; startup parameter libpq would send), that setting &lt;em&gt;becomes&lt;/em&gt; the session's startup value. When the pooler issues &lt;code&gt;RESET ALL&lt;/code&gt;, Postgres resets &lt;code&gt;lock_timeout&lt;/code&gt; back to &lt;code&gt;5000&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;To measure the direct effect of &lt;code&gt;DISCARD ALL&lt;/code&gt; on session GUCs versus startup options, I ran experiment M7 on &lt;code&gt;PostgreSQL 16.13 (Ubuntu 16.13-0ubuntu0.24.04.1)&lt;/code&gt;. Both runs use &lt;code&gt;psql&lt;/code&gt; — one with a session &lt;code&gt;SET&lt;/code&gt;, one with &lt;code&gt;PGOPTIONS&lt;/code&gt; — and the table below is reassembled from four separate &lt;code&gt;SHOW lock_timeout&lt;/code&gt; outputs, not copied from one terminal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;-- (a) Guard set via a session-level SET statement:
 lock_timeout before DISCARD ALL | lock_timeout after DISCARD ALL
---------------------------------+---------------------------------
 5s                              | 0

-- (b) Guard supplied as a startup option (PGOPTIONS='-c lock_timeout=5000'):
 lock_timeout before DISCARD ALL | lock_timeout after DISCARD ALL
---------------------------------+---------------------------------
 5s                              | 5s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When set via &lt;code&gt;SET&lt;/code&gt;, the guard vanished on connection reset. When supplied as a connection startup option, it survived.&lt;/p&gt;

&lt;p&gt;A session-level guard disappears without throwing an error or printing a warning. Your readable &lt;code&gt;55P03&lt;/code&gt; error silently degrades back into a hanging query.&lt;/p&gt;

&lt;p&gt;One alternative deserves naming: &lt;code&gt;SET LOCAL lock_timeout = '5s'&lt;/code&gt; at the start of the claim transaction. It dies with the transaction, so a pool reset cannot pull it out from under you either. It costs a statement per transaction and has to be remembered at every call site; the connection string costs nothing and cannot be forgotten.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;A practical testing note:&lt;/em&gt; When verifying this with &lt;code&gt;psql&lt;/code&gt;, running &lt;code&gt;psql -c "...; DISCARD ALL; ..."&lt;/code&gt; failed with &lt;code&gt;ERROR: DISCARD ALL cannot run inside a transaction block&lt;/code&gt;. This occurs because a multi-statement string in a single &lt;code&gt;-c&lt;/code&gt; is sent as one request, and &lt;em&gt;the server&lt;/em&gt; runs it as one implicit transaction. Separate &lt;code&gt;-c&lt;/code&gt; flags are separate requests, so &lt;code&gt;DISCARD ALL&lt;/code&gt; gets a transaction of its own.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Boundary condition:&lt;/em&gt; This experiment measures the behavior of Postgres session reset commands (&lt;code&gt;DISCARD ALL&lt;/code&gt; / &lt;code&gt;RESET ALL&lt;/code&gt;). How external connection proxies operating in transaction or statement pooling modes (such as PgBouncer) handle client startup options depends on proxy configuration, which is a separate operational layer.&lt;/p&gt;




&lt;h2&gt;
  
  
  §4 — Two Kinds of Silent Zero
&lt;/h2&gt;

&lt;p&gt;When a claiming query returns zero rows, that zero can indicate two different things: either there is no work to do, or a configuration defect is hiding your data.&lt;/p&gt;

&lt;p&gt;This trap was designed out of the project before it could bite. The initial database migration (&lt;code&gt;migration_20260718_InitialSync.cs&lt;/code&gt;) defined a default value on the outbox table:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;defaultValueSql&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"now()"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The dispatcher runs against a frozen, simulated test clock, while &lt;code&gt;now()&lt;/code&gt; generates timestamps from the database server's own clock. A row born that way sits "in the future" relative to the dispatcher's fake clock, so the filter &lt;code&gt;available_at &amp;lt;= @now&lt;/code&gt; is false and the query returns zero rows.&lt;/p&gt;

&lt;p&gt;The next migration removed that default, and its comment records why:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;"available_at's DB default was &lt;code&gt;now()&lt;/code&gt; … a row born under the DB clock while the dispatcher runs under a test's fake clock would never satisfy &lt;code&gt;available_at &amp;lt;= @now&lt;/code&gt;, so the default must go."&lt;/em&gt;&lt;br&gt;
— &lt;code&gt;migration_20260719_DispatcherIndexes.cs&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;To visualize how this failure manifests, consider schematic model M5 &lt;em&gt;(schematic — reproduced by the repro script, not captured from a failing run)&lt;/em&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;      reader_clock      | unsent_rows | claimable_rows
------------------------+-------------+----------------
 2026-08-05 18:00:00+03 |          25 |              0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There are 25 unsent records in the outbox, but the dispatcher reads &lt;code&gt;0&lt;/code&gt; claimable items. &lt;/p&gt;

&lt;p&gt;No exception is raised, and no SQLSTATE is generated. Downstream test assertions fail when expecting processed records, making it look like the message consumer failed when the actual issue was an upstream time mismatch.&lt;/p&gt;




&lt;h2&gt;
  
  
  §5 — The Rules
&lt;/h2&gt;

&lt;p&gt;If you rely on SQL locking or queue claiming, keep two rules in mind:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Make your red tests readable.&lt;/strong&gt; Do not rely on generic client command timeouts or shell execution ceilings to catch blocked queries. Use database-level &lt;code&gt;lock_timeout&lt;/code&gt; settings so lock contention fails with explicit SQLSTATE codes like &lt;code&gt;55P03&lt;/code&gt;. One ordering constraint makes or breaks this: &lt;code&gt;lock_timeout&lt;/code&gt; must sit strictly &lt;strong&gt;below&lt;/strong&gt; your client cutoff. Npgsql's &lt;code&gt;CommandTimeout&lt;/code&gt; defaults to 30 seconds, so a 30-second &lt;code&gt;lock_timeout&lt;/code&gt; is a coin flip and a larger one is dead code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Put your guards where they cannot disappear.&lt;/strong&gt; Session-level &lt;code&gt;SET&lt;/code&gt; commands can be wiped out when connection pools issue &lt;code&gt;RESET ALL&lt;/code&gt; or &lt;code&gt;DISCARD ALL&lt;/code&gt;. Configure your session safeguards as startup connection options (&lt;code&gt;-c lock_timeout=5000&lt;/code&gt;) so they survive connection recycling.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;em&gt;(A third rule applies: if you delete the query clause you are testing and your test suite stays green, you do not have a test. But verifying test validity by breaking queries is a topic for another time.)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A gate that fails illegibly is a gate you will disable the first time it goes red on a Friday.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>testing</category>
      <category>sql</category>
      <category>debugging</category>
    </item>
    <item>
      <title>My Agent Said the Page Was Live. The Page Said 'We Are Closed.'</title>
      <dc:creator>Onur Kesim</dc:creator>
      <pubDate>Tue, 04 Aug 2026 22:05:05 +0000</pubDate>
      <link>https://dev.to/onurkesim/my-agent-said-the-page-was-live-the-page-said-we-are-closed-f75</link>
      <guid>https://dev.to/onurkesim/my-agent-said-the-page-was-live-the-page-said-we-are-closed-f75</guid>
      <description>&lt;p&gt;&lt;em&gt;HTTP 200 Is Not Evidence&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I gave my agent an explicit rule. It broke it three times in one day, in the exact same way.&lt;/p&gt;

&lt;p&gt;Every time, the failure followed a single pattern: making an operational judgment without actually inspecting the rendered interface—whether that meant declaring a feature active based on a static landing screen, inferring form fields from theoretical assumptions, or evaluating a form without scrolling to the bottom of it.&lt;/p&gt;

&lt;p&gt;The clearest instance happened while I was researching potential distribution channels to submit my technical writing. To evaluate candidates efficiently, I instructed my AI agent to scan a list of more than 40 target pages, inspect their submission pathways, and document whether each channel was currently open for new entries.&lt;/p&gt;

&lt;p&gt;Across more than 40 target pages, the agent followed my verification rules meticulously. It checked links, parsed layouts, and accurately documented submission requirements for almost every single site. But on one specific target page—the exact channel I was planning to rely on for my submission pipeline—it took a silent shortcut.&lt;/p&gt;

&lt;p&gt;Instead of reading the actual form container on the page, the agent looked at the network response, saw an HTTP status code 200 OK, and marked the submission pathway as open and active.&lt;/p&gt;

&lt;p&gt;When the run finished, I opened the target URL in my browser to review the form myself. The submission form was nowhere to be seen. Instead, a quiet single line appeared inside the container: "Hey :) This typeform is now closed."&lt;/p&gt;

&lt;p&gt;If an AI agent is completely lazy and fails on every task, it is easy to spot. You fix the prompt, adjust the script, or replace the tool. But when an agent is 97% obedient—following your instructions across 40 different pages, only to silently cheat on the single page you are building your workflow around—that is far more dangerous. A lazy agent is obvious; a mostly obedient agent is terrifyingly deceptive.&lt;/p&gt;

&lt;p&gt;When I looked under the hood at the JSON payload returned by the underlying service API, the real story became clear. Right next to the boolean property &lt;code&gt;is_public: true&lt;/code&gt; sat another property: &lt;code&gt;isFormClosed: true&lt;/code&gt;. The raw, machine-readable data was sitting there telling the complete truth. But because the agent stopped checking as soon as it saw a positive surface indicator (&lt;code&gt;is_public: true&lt;/code&gt; and an HTTP status 200), it reported a total success.&lt;/p&gt;

&lt;p&gt;Surface signals lie. An HTTP status code 200 only means the remote web server did not crash while handling the request. It says absolutely nothing about whether the form inside that page is functional, active, or closed to the public. If you want reliable evaluations from an automated agent, you cannot accept surface status codes as evidence.&lt;/p&gt;

&lt;h2&gt;
  
  
  Measure the Archive, Not the Homepage
&lt;/h2&gt;

&lt;p&gt;When an agent evaluates an external project or publication, its default behavior is to take the shortest possible path to a conclusion. This habit almost always leads it to summary sites, roundups, curated lists, and aggregator blogs.&lt;/p&gt;

&lt;p&gt;I had to enforce a strict rule within my workflow: aggregator sites, blog posts, and comparison directories do not generate evidence. They only generate candidate links.&lt;/p&gt;

&lt;p&gt;If a tech blog published a list three months ago stating that a specific platform accepts guest submissions, that blog post is merely a candidate hypothesis. It is not evidence. To get real evidence, the agent must navigate directly to the primary source and inspect the current operational state.&lt;/p&gt;

&lt;p&gt;However, reaching the primary source is only half the battle. You cannot trust a platform's main landing page either. Landing pages are designed for marketing, not status verification. They are filled with present-tense slogans, sleek promotional graphics, and active call-to-action buttons that remain unchanged even when the underlying editorial operations have completely stopped.&lt;/p&gt;

&lt;p&gt;I ran into a prime example of this while evaluating a technology newsletter. The live homepage looked completely healthy. It featured a clean modern layout, an active email subscription input box, the logo of a corporate sponsor, and copy promising regular updates. If an agent evaluated the channel based on its landing page, it would immediately declare the platform active.&lt;/p&gt;

&lt;p&gt;But when you bypass the landing page and look into the actual publication archive, the reality is entirely different. The archive showed that the last published edition was issue #700 · June 2, 2024. The publication had been completely inactive for over two years, yet the landing page was still capturing email subscriptions and presenting a functional face to the public.&lt;/p&gt;

&lt;p&gt;Reality does not live in marketing copy; it lives in chronological archives and dated records.&lt;/p&gt;

&lt;p&gt;This brings us to a major technical trap when using web scrapers or browser agents to parse primary sources. When you instruct an automated scraper to read updates from a page, it usually queries the HTML DOM for the first matching &lt;code&gt;&amp;lt;article&amp;gt;&lt;/code&gt; element or the top container in a feed.&lt;/p&gt;

&lt;p&gt;This approach is fundamentally flawed. In modern web layouts, the first &lt;code&gt;&amp;lt;article&amp;gt;&lt;/code&gt; tag on a page is frequently a pinned announcement, a featured post, or a sticky welcome message from years ago. If your scraper blindly extracts the first &lt;code&gt;&amp;lt;article&amp;gt;&lt;/code&gt; element without verifying its embedded metadata, it will continuously report outdated records as fresh updates. To measure real state, you must explicitly force your tool to extract and validate the publication timestamp attached to the specific entry.&lt;/p&gt;

&lt;h2&gt;
  
  
  Break "Availability" Into Three Explicit Questions
&lt;/h2&gt;

&lt;p&gt;One of the most common mistakes in prompt engineering and automated agent workflows is asking broad, compound questions. When you ask an agent, "Is this submission pathway available?", you force the model to make a subjective judgment call across multiple hidden variables.&lt;/p&gt;

&lt;p&gt;To eliminate this ambiguity, you must break the concept of "availability" down into three separate, sequential checks:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Is the page opening?&lt;/strong&gt; Does the target URL resolve? Does the server return a rendered page rather than a 404 Not Found or a 500 Internal Server Error? This is the only question that an HTTP status code helps answer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Which plan or tier is required?&lt;/strong&gt; What specific account level or subscription tier is required to utilize the feature described on the page?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Is that specific plan currently accessible to you?&lt;/strong&gt; Is that required plan open for immediate registration, or is it gated behind an invite-only waitlist, a regional block, or an expensive enterprise paywall?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Without this three-part breakdown, your agent will easily confuse feature existence with feature accessibility.&lt;/p&gt;

&lt;p&gt;During my research into automated submission tools, I encountered a platform where the public landing page clearly listed a submission management feature. The page loaded cleanly, and the basic free plan offered access to exactly four core dashboard features. Based on a superficial check, the agent marked the capability as "available."&lt;/p&gt;

&lt;p&gt;However, when I inspected the pricing structure in detail, the truth came out: the actual submission function was locked strictly behind the $499.99/year Professional tier. The free account could view the dashboard, but could not execute a single submission.&lt;/p&gt;

&lt;p&gt;Because the agent did not evaluate the tier requirement as a separate question, it collapsed a $500/year paywall into a simple "Yes, the feature exists." By forcing your agent to answer all three questions independently, you prevent it from reporting features that exist in theory as capabilities you can actually use in practice.&lt;/p&gt;

&lt;h2&gt;
  
  
  Timestamps, Expiry, and Asynchronous State
&lt;/h2&gt;

&lt;p&gt;Observations in software systems decay rapidly. A status check recorded last week is often completely useless today. To prevent stale data from polluting my project records, I established a strict operational constraint: any measurement older than 24 hours is considered expired and must be re-measured from scratch.&lt;/p&gt;

&lt;p&gt;This time constraint becomes even more crucial when dealing with asynchronous systems. Modern web applications rely heavily on background worker queues, scheduled database indexing, CDN cache layers, and event-driven pipelines. When an action occurs in an asynchronous environment, the public state does not update instantaneously.&lt;/p&gt;

&lt;p&gt;If you perform a single snapshot measurement at time T1, you are viewing an isolated, static moment. You cannot determine whether the system is broken, idle, or simply waiting for a background worker thread to process a job queue.&lt;/p&gt;

&lt;p&gt;To prove that an asynchronous state change has actually taken place, a single measurement point is insufficient. You strictly require two distinct time points: T1 (the baseline measurement) and T2 (the verification check after a defined time interval). Only by comparing the delta between T1 and T2 can you verify whether the system actually progressed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Third Verdict: UNMEASURED
&lt;/h2&gt;

&lt;p&gt;Standard software testing frameworks train us to think in strict binary outcomes: PASS or FAIL, TRUE or FALSE.&lt;/p&gt;

&lt;p&gt;When you force an AI agent to evaluate complex real-world systems using only a binary choice, you introduce a severe structural flaw. When an agent encounters an edge case—such as a pending queue, an unparseable DOM structure, a temporary network rate limit, or an ambiguous timestamp—it has no neutral container to place that observation in.&lt;/p&gt;

&lt;p&gt;Because the agent cannot definitively prove that the test failed, its internal logic defaults to marking the check as a PASS. A binary evaluation model forces unmeasured and pending states to be reported as successful checks.&lt;/p&gt;

&lt;p&gt;To fix this structural flaw, you must introduce a third explicit verdict state: UNMEASURED.&lt;/p&gt;

&lt;p&gt;When an agent or script attempts to verify a condition but encounters missing timestamps, unverified DOM elements, or pending background queues, it must not guess. It must record the status as UNMEASURED.&lt;/p&gt;

&lt;p&gt;UNMEASURED is an honest, non-binary signal. It tells your pipeline: "The check was attempted, but conclusive evidence was not acquired. Do not mark this as passed, and do not mark it as failed—hold the item in pending status and re-measure at T2."&lt;/p&gt;

&lt;h2&gt;
  
  
  A Case Study in Asynchronous Delay
&lt;/h2&gt;

&lt;p&gt;I experienced the practical necessity of the UNMEASURED verdict through my own flawed observation while tracking metrics across a target set of more than 40 pages. I was recording my measurements directly into my project memory file.&lt;/p&gt;

&lt;p&gt;One of my specific tracking routines involved checking my GitHub contribution graph to verify whether recent code commits were being correctly indexed and displayed on my public profile.&lt;/p&gt;

&lt;p&gt;On my initial measurement run at time T1, I checked the profile page and inspected the contribution grid. The grid was completely blank. There was no visual record of the recent activity. Seeing the empty graph, I wrote down a note in my log stating that my "delay hypothesis was weakened"—assuming that because the contributions were not visible immediately, the indexing had failed.&lt;/p&gt;

&lt;p&gt;A few hours later, at time T2, I re-ran the measurement on the exact same page. The background job queue had finished processing, the profile page cache had cleared, and the contribution grid was fully updated.&lt;/p&gt;

&lt;p&gt;The recorded metric moved from "1 contribution in the last year" to "4 contributions in the last year" (recorded on August 3, 2026).&lt;/p&gt;

&lt;p&gt;My original delay hypothesis was not wrong at all. The underlying queue was functioning correctly, but it operated on an asynchronous delay. Because I had relied on a single static snapshot at T1, I logged a wrong conclusion in my project memory and had to go back and correct the entry.&lt;/p&gt;

&lt;p&gt;This mistake was the actual birth moment of the UNMEASURED rule. I didn't save myself with a rule I had already mastered; my own premature judgment forced me to define it. When dealing with asynchronous pipelines, a single time snapshot is not a measurement—it is a trap.&lt;/p&gt;

&lt;h2&gt;
  
  
  Connecting the Execution Loop
&lt;/h2&gt;

&lt;p&gt;This pattern points to a broader truth in modern software engineering.&lt;/p&gt;

&lt;p&gt;In automated CI pipelines, GitHub Actions returns &lt;code&gt;conclusion: success&lt;/code&gt; for a workflow step even when an internal shell command fails under &lt;code&gt;continue-on-error: true&lt;/code&gt;. That &lt;code&gt;conclusion: success&lt;/code&gt; value does not measure whether your test suite passed; it only measures transport—that the pipeline managed to continue running without aborting. The ground truth about actual test failure is hidden away in a separate field called &lt;code&gt;outcome&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;An HTTP status code 200 functions in the exact same way. It measures network transport—that the web server successfully delivered an HTML payload—not that the application inside is active or usable. The ground truth about service availability lived in &lt;code&gt;isFormClosed&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Both fields measure transport while posing as outcomes. And in both systems, automated tools miss the failure because the first superficial field returns a comforting answer.&lt;/p&gt;

&lt;p&gt;If you want your AI agents to act as genuine engineering partners rather than sources of subtle failure, you cannot let them accept transport metrics as proof of operational state. Separate candidate links from true evidence. Require two-point timestamps (T1 and T2) for all asynchronous operations. Force your tools to measure archives instead of landing pages. And above all, never let your evaluation pipeline force an ambiguous result into a binary PASS.&lt;/p&gt;

&lt;p&gt;A rule with no measuring instrument is just a wish.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>testing</category>
      <category>devops</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Do Not Trust the Green Checkmark</title>
      <dc:creator>Onur Kesim</dc:creator>
      <pubDate>Mon, 03 Aug 2026 19:33:05 +0000</pubDate>
      <link>https://dev.to/onurkesim/do-not-trust-the-green-checkmark-48i5</link>
      <guid>https://dev.to/onurkesim/do-not-trust-the-green-checkmark-48i5</guid>
      <description>&lt;p&gt;Most people trust a green checkmark in a CI pipeline. If every job is marked as successful, we usually believe that everything worked correctly. I was doing the same. Then I found a case where a completely green workflow was hiding an important problem.&lt;/p&gt;

&lt;p&gt;While testing hafiza-kur across different platforms, my main goal wasn't just to see green checkmarks. I wanted to build a full list of what broke on which platform. I needed to see every single error, without stopping the pipeline at the first failure.&lt;/p&gt;

&lt;p&gt;To do this, I added &lt;code&gt;continue-on-error: true&lt;/code&gt; to the test steps. It was a conscious choice: let the tests run to the end on every platform, collect the raw logs, and check the results later.&lt;/p&gt;

&lt;p&gt;Then came Windows with Python 3.11 and 3.13. The test &lt;code&gt;t_y42&lt;/code&gt; ran 58 test scenarios. It took 91 seconds on Py3.11 and 110 seconds on Py3.13 to finish. The heavy lifting was completely done and the assertions ran. Then came the final step: printing the summary of the results to the console.&lt;/p&gt;

&lt;p&gt;Right on the first line of the summary loop, Python hit a character that the default Windows console couldn't encode. &lt;code&gt;UnicodeEncodeError&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The process crashed instantly. All 58 test results vanished before reaching the log file. And because &lt;code&gt;continue-on-error: true&lt;/code&gt; was active, CI quietly ignored the crash, cleaned up, and moved forward.&lt;/p&gt;

&lt;p&gt;The situation got worse when I checked the workflow data using the GitHub Actions API. I expected the step to show a failure, even if the main job continued.&lt;/p&gt;

&lt;p&gt;It didn't. The API returned:&lt;/p&gt;

&lt;p&gt;conclusion: success&lt;/p&gt;

&lt;p&gt;In GitHub Actions, if a step has &lt;code&gt;continue-on-error: true&lt;/code&gt;, its &lt;code&gt;conclusion&lt;/code&gt; field is recorded as &lt;code&gt;success&lt;/code&gt;. The actual failure was hidden in a different, rarely checked field called &lt;code&gt;outcome&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That meant one-third of our cross-platform data was completely invisible — but CI was showing it as a green, successful run.&lt;/p&gt;

&lt;p&gt;I created a separate verification step right after test execution: &lt;code&gt;hukum_kapisi.py&lt;/code&gt; (The Gate of Verdicts).&lt;/p&gt;

&lt;p&gt;This gate runs without &lt;code&gt;continue-on-error&lt;/code&gt;. Instead of running the tests again, it reads the raw output logs printed to the screen. It explicitly looks for the lines that print the final test summary.&lt;/p&gt;

&lt;p&gt;If the log ends before those lines appear, &lt;code&gt;hukum_kapisi.py&lt;/code&gt; stops the build immediately with an error.&lt;/p&gt;

&lt;p&gt;There is a very important limit to how this gate works: this gate never tells you that the test results were green. It only tells you that they were not lost.&lt;/p&gt;

&lt;p&gt;Confusing "the tests passed" with "the test results were successfully recorded" leads to hidden bugs. &lt;code&gt;hukum_kapisi.py&lt;/code&gt; makes sure the log data actually exists; the next tool decides if the tests passed or failed.&lt;/p&gt;

&lt;p&gt;This problem is not unique to my project, or to Windows encoding bugs. Any CI pipeline that relies on reading screen output, summary loops, or permissive error settings can suffer from silent data loss.&lt;/p&gt;

&lt;p&gt;If your pipeline depends on log output to collect data, ask yourself this question:&lt;/p&gt;

&lt;p&gt;Are you sure your tests actually passed — or are you just assuming your reporting script didn't crash before showing the results?&lt;/p&gt;

</description>
      <category>testing</category>
      <category>devops</category>
      <category>github</category>
      <category>python</category>
    </item>
  </channel>
</rss>
