<?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: Raknaos</title>
    <description>The latest articles on DEV Community by Raknaos (@raknaos).</description>
    <link>https://dev.to/raknaos</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4114622%2F237cf8c8-193e-4bca-bfa5-5d3530a06261.webp</url>
      <title>DEV Community: Raknaos</title>
      <link>https://dev.to/raknaos</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/raknaos"/>
    <language>en</language>
    <item>
      <title>My HTTP timing tool could not even start on Python 3.11</title>
      <dc:creator>Raknaos</dc:creator>
      <pubDate>Sat, 19 Sep 2026 09:07:25 +0000</pubDate>
      <link>https://dev.to/raknaos/my-http-timing-tool-could-not-even-start-on-python-311-4li7</link>
      <guid>https://dev.to/raknaos/my-http-timing-tool-could-not-even-start-on-python-311-4li7</guid>
      <description>&lt;p&gt;The first version of a small HTTP timing tool failed before it could measure a request.&lt;/p&gt;

&lt;p&gt;That sounds like an ordinary syntax mistake, but it exposed exactly why I run a tool locally before I write about it. The README showed a useful command and a self-test. The repository looked like a tiny, dependency-free utility. On this machine, the advertised self-test did not reach the local HTTP server at all: Python stopped while parsing the file.&lt;/p&gt;

&lt;p&gt;The failure was in the renderer, not in DNS, TCP, TLS, or HTTP. One f-string used a backslash escape inside an expression:&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="n"&gt;lines&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="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;  &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;total&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;  &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s"&gt;u2588&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;width&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;  &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mf"&gt;7.1&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;ms&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Python 3.11 reports &lt;code&gt;SyntaxError: f-string expression part cannot include a backslash&lt;/code&gt;. The README says the project requires Python 3.7+, so this is not a theoretical compatibility edge case. It is a startup failure on a supported interpreter.&lt;/p&gt;

&lt;p&gt;That was the problem I wanted to solve in the first place: when an HTTP request is slow, “the server is slow” is not a diagnosis. The delay may be name resolution, the TCP handshake, TLS, waiting for the first response byte, or downloading the response. I wanted a small command that made those phases visible without installing a package or remembering a larger profiling stack.&lt;/p&gt;

&lt;h2&gt;
  
  
  The design is deliberately small
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/Raknaos/httpstat-lite" rel="noopener noreferrer"&gt;httpstat-lite&lt;/a&gt; is a standalone Python script. The repository contains &lt;code&gt;httpstat_lite.py&lt;/code&gt;, and its implementation uses the standard library: &lt;code&gt;argparse&lt;/code&gt;, &lt;code&gt;socket&lt;/code&gt;, &lt;code&gt;ssl&lt;/code&gt;, &lt;code&gt;time&lt;/code&gt;, and &lt;code&gt;urllib.parse&lt;/code&gt;, with the self-test importing &lt;code&gt;http.server&lt;/code&gt; and &lt;code&gt;threading&lt;/code&gt; when needed.&lt;/p&gt;

&lt;p&gt;The core measurement function records a wall-clock start, resolves the host with &lt;code&gt;socket.getaddrinfo&lt;/code&gt;, opens an IPv4 TCP socket, optionally wraps it with the default TLS context, sends a minimal HTTP/1.1 GET request, and measures the first received byte as TTFB. It then reads until the socket closes and records the download and total durations.&lt;/p&gt;

&lt;p&gt;That decomposition is useful because each number answers a different question. DNS tells me whether resolving the host is expensive. Connect tells me whether reaching the address is expensive. TLS separates the secure handshake from the TCP connection. TTFB includes the wait after sending the request until the first byte arrives. Download measures the remaining read. Total is the complete elapsed time recorded by the script.&lt;/p&gt;

&lt;p&gt;The request itself is intentionally direct:&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="n"&gt;req&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;GET &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; HTTP/1.1&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s"&gt;r&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s"&gt;nHost: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s"&gt;r&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s"&gt;nConnection: close&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s"&gt;r&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s"&gt;n&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s"&gt;r&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s"&gt;n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;sock&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;span class="n"&gt;sock&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;recv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# first byte
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is no HTTP client dependency and no attempt to become a general-purpose HTTP implementation. The script is a timing probe, not a browser and not a response validator.&lt;/p&gt;

&lt;p&gt;The output is an ASCII chart. Each phase receives a bar proportional to its share of the measured total, followed by milliseconds. A successful run is intended to look like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;         dns  ████   12.3ms
     connect  ██████   25.1ms
         tls  ████   15.0ms
        ttfb  ██████████████████   80.2ms
    download  ███   10.5ms
       total  ████████████████████████████████████████  143.1ms
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The normal command is one line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python3 httpstat_lite.py https://example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The repository also defines an offline self-test. It starts a local &lt;code&gt;http.server&lt;/code&gt;, measures a loopback URL, checks that DNS, connect, TTFB, download, and total timings exist, renders the chart, and prints &lt;code&gt;self-test: PASS&lt;/code&gt; when those assertions succeed. Running that command is part of my verification routine, not a claim I can replace with a README reading.&lt;/p&gt;

&lt;p&gt;On the checked-out source, however, the self-test currently fails earlier with the Python 3.11 syntax error shown above. That is the honest state of this revision. The measurement approach is easy to inspect, but the advertised compatibility claim and the executable source disagree until the f-string is rewritten.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix is smaller than the diagnosis
&lt;/h2&gt;

&lt;p&gt;The problematic expression does not need to build the bar inside the f-string. The renderer already does this correctly for every other phase:&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="n"&gt;bar&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s"&gt;u2588&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;bar_len&lt;/span&gt;
&lt;span class="n"&gt;lines&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="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;  &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;phase&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;  &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;bar&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;  &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;ms&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mf"&gt;7.1&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;ms&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The total line should follow the same rule: create the bar before formatting the string, or use a literal character outside the expression. That keeps the code compatible with the stated Python 3.7+ requirement and makes the line easier to read.&lt;/p&gt;

&lt;p&gt;This is also why I prefer a local self-test over a green-looking interface. A small CLI can have a simple design and still fail at the first byte of execution. In this case, the failure is not hidden in a rare network path; it is visible to the parser before any socket is opened.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it does not do
&lt;/h2&gt;

&lt;p&gt;This tool does not measure every possible HTTP detail. It uses IPv4 address resolution and the first address returned by &lt;code&gt;getaddrinfo&lt;/code&gt;. It sends a GET request and reads until the connection closes. It does not expose response headers, status-code validation, redirects, HTTP/2, HTTP/3, proxy configuration, or a full response body report. Its TTFB number is the elapsed time until one byte is received, not a server-side processing measurement.&lt;/p&gt;

&lt;p&gt;It also cannot tell me why a phase is slow. It shows where the time was observed. I still need logs, repeated samples, and knowledge of the network path to explain the cause.&lt;/p&gt;

&lt;p&gt;The cost is the standard library already present in Python. There is no package installation and no network dependency for the self-test once the syntax issue is fixed. The normal URL measurement, of course, contacts the URL I provide.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;The source, MIT license, usage example, and self-test are in the repository:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/Raknaos/httpstat-lite" rel="noopener noreferrer"&gt;https://github.com/Raknaos/httpstat-lite&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Download the script and run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python3 httpstat_lite.py &lt;span class="nt"&gt;--self-test&lt;/span&gt;
python3 httpstat_lite.py https://example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run the first command on the exact revision you intend to use. That one habit caught this bug before the timing chart could pretend to be evidence.&lt;/p&gt;

</description>
      <category>debugging</category>
      <category>programming</category>
      <category>python</category>
      <category>tools</category>
    </item>
    <item>
      <title>My CSV diff tool reported a new row as removed, and its self-test cannot run</title>
      <dc:creator>Raknaos</dc:creator>
      <pubDate>Tue, 15 Sep 2026 09:12:34 +0000</pubDate>
      <link>https://dev.to/raknaos/my-csv-diff-tool-reported-a-new-row-as-removed-and-its-self-test-cannot-run-4mpk</link>
      <guid>https://dev.to/raknaos/my-csv-diff-tool-reported-a-new-row-as-removed-and-its-self-test-cannot-run-4mpk</guid>
      <description>&lt;p&gt;Last week I compared two CSV snapshots of a price table, one export from staging, one from production, with a tool from my lab: &lt;code&gt;csv-key-diff&lt;/code&gt;. It takes two CSVs, a key column, and returns a JSON object with &lt;code&gt;added&lt;/code&gt;, &lt;code&gt;removed&lt;/code&gt; and &lt;code&gt;changed&lt;/code&gt; arrays. It is 76 lines of Python, stdlib-only, nothing to install.&lt;/p&gt;

&lt;p&gt;The production export had a row that staging did not have. That is an addition. The tool reported it under &lt;code&gt;removed&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I only caught it because the row counts did not line up between the two exports, and the JSON said &lt;code&gt;"added": []&lt;/code&gt; while naming exactly one row in &lt;code&gt;removed&lt;/code&gt;. One of those two statements had to be about my new row, and the array claiming "nothing appeared" was the wrong one.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the code actually says
&lt;/h2&gt;

&lt;p&gt;The whole comparison is three lines:&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;def&lt;/span&gt; &lt;span class="nf"&gt;compute_diff&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;data2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;added&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;data1&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;data1&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;k&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;data2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;removed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;data2&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;data2&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;k&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;data1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;changed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;data1&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;data1&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;data2&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;data1&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;data2&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;added&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;added&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;removed&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;removed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;changed&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;changed&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;added&lt;/code&gt; iterates over &lt;code&gt;data1&lt;/code&gt;, the &lt;strong&gt;first&lt;/strong&gt; file argument. &lt;code&gt;removed&lt;/code&gt; iterates over &lt;code&gt;data2&lt;/code&gt;, the &lt;strong&gt;second&lt;/strong&gt;. Read as an operation it is coherent: "what the first file holds that the second lost". Read as English, it is backwards. Every diff I use daily, &lt;code&gt;diff old new&lt;/code&gt;, &lt;code&gt;git diff a b&lt;/code&gt;, is oriented forward: what the new file brought.&lt;/p&gt;

&lt;p&gt;Here is what that costs me on real files:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ python3 csv_key_diff.py a.csv b.csv --key id
{"added": [], "removed": [{"id": "3", "name": "qux"}], "changed": [{"id": "2", "name": "bar"}]}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;b.csv&lt;/code&gt; is the file where id 3 appeared and where id 2's name became &lt;code&gt;BAZ&lt;/code&gt;. The tool calls the arrival a removal and reports the row it &lt;em&gt;replaced&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;That second part is a separate decision hiding in the same line: &lt;code&gt;changed&lt;/code&gt; yields &lt;code&gt;data1[k]&lt;/code&gt;, the old row. A &lt;code&gt;changed&lt;/code&gt; entry tells you which key moved and where it came from, never where it went. For a review workflow, the value you actually need is the half that is missing.&lt;/p&gt;

&lt;p&gt;The README line for the tool is &lt;code&gt;python csv_key_diff.py path/to/file1.csv path/to/file2.csv --key id&lt;/code&gt;. file1, file2. Nothing in the text says which one is the baseline, so the only way to learn the orientation is to be surprised by it once.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two failures, one silent exit code
&lt;/h2&gt;

&lt;p&gt;I fed it a file with a duplicated key to see what would happen. It exited 2 and printed nothing at all:&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;for&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;reader&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same code 2 covers a missing key column:&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="n"&gt;key&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;reader&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;fieldnames&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Distinct causes, identical exit status, no message. Exit 2 is a fine signal for "halt the pipeline" and a useless one for "why did it halt at 2am". The other tool in this family I wrote, &lt;code&gt;jsonl-cli&lt;/code&gt;, prints the line number of every broken record to stderr before it fails; that is the bar.&lt;/p&gt;

&lt;h2&gt;
  
  
  Everything is a string, compared as such
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;csv.DictReader&lt;/code&gt; hands back strings and the comparison is plain &lt;code&gt;!=&lt;/code&gt;. Same key, quantity &lt;code&gt;2&lt;/code&gt; against &lt;code&gt;2.0&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{"added": [], "removed": [], "changed": [{"id": "1", "qty": "2"}]}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A numeric no-op becomes a change. A row with fewer columns than the header comes back with a JSON null:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{"added": [], "removed": [], "changed": [{"id": "2", "name": "bar", "note": null}]}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So null means "this cell was empty" and also "this row never had that column" — the tool cannot tell those apart, and neither can the reader. An empty input file crashes outright, because &lt;code&gt;fieldnames&lt;/code&gt; is None and &lt;code&gt;key not in None&lt;/code&gt; raises a TypeError: exit 1, traceback, no explanation.&lt;/p&gt;

&lt;h2&gt;
  
  
  The self-test does not test the diff
&lt;/h2&gt;

&lt;p&gt;The README advertises:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python csv_key_diff.py &lt;span class="nt"&gt;--self-test&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That command has never worked. &lt;code&gt;--key&lt;/code&gt; is declared &lt;code&gt;required=True&lt;/code&gt; and both paths are positional, so argparse rejects it before &lt;code&gt;test_self()&lt;/code&gt; is ever reached:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;usage: csv_key_diff.py [-h] --key KEY [--self-test] csv1 csv2
csv_key_diff.py: error: the following arguments are required: csv1, csv2, --key
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I had documented a command I never typed. So I typed it with the positional arguments filled in, and looked at what the harness does:&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;def&lt;/span&gt; &lt;span class="nf"&gt;run_test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;expected_exit&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;subprocess&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;executable&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;__file__&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;--key&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;--self-test&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="n"&gt;capture_output&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;returncode&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;expected_exit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The child process is launched &lt;strong&gt;with &lt;code&gt;--self-test&lt;/code&gt;&lt;/strong&gt;. It does not run the comparison it is supposed to be checking; it calls &lt;code&gt;test_self()&lt;/code&gt; again, which spawns four children, each spawning four more. I ran it in a sandbox capped at 400 processes and a 6-second timeout: it never returned, the timeout killed it with 124.&lt;/p&gt;

&lt;p&gt;Which means the four &lt;code&gt;run_test&lt;/code&gt; calls in the file assert an exit code about a self-invocation, never about &lt;code&gt;added&lt;/code&gt;, &lt;code&gt;removed&lt;/code&gt; or &lt;code&gt;changed&lt;/code&gt;. The happy path and the two error paths I describe above are all untested, and the recursion is what hid that from me: a suite that cannot finish looks like a suite that works if you never wait for it.&lt;/p&gt;

&lt;p&gt;The repo ships &lt;code&gt;tests/identical.csv&lt;/code&gt; and &lt;code&gt;tests/missing.csv&lt;/code&gt;, 19 bytes each, and they are the same git blob — identical contents, despite the names. Neither filename appears anywhere in &lt;code&gt;csv_key_diff.py&lt;/code&gt;. The CI workflow auto-detects checks by globbing &lt;code&gt;test_*.py&lt;/code&gt; and &lt;code&gt;*_test.py&lt;/code&gt;; this repo has neither, so what runs green is &lt;code&gt;python3 -m py_compile&lt;/code&gt; over the one Python file. Single commit, &lt;code&gt;f7348e1&lt;/code&gt;, 2026-09-14, MIT, zero dependencies.&lt;/p&gt;

&lt;p&gt;I am not hiding that. A green CI, a self-test section in the README, and no behavioural coverage at all can absolutely coexist, because each of those signals is checked independently and none of them asks whether the tool answers the question you built it for.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would change, and what I left alone
&lt;/h2&gt;

&lt;p&gt;Flipping two variable names would fix the orientation, and I would do it, because "forward like every other diff" is what readers assume. Printing one line to stderr before &lt;code&gt;sys.exit(2)&lt;/code&gt; would make the failure diagnosable. Dropping &lt;code&gt;--self-test&lt;/code&gt; from the child arguments would turn the harness into tests that actually reach &lt;code&gt;compute_diff&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;What I would not fix is the string comparison. Once you start coercing numbers you own a type guesser, and &lt;code&gt;csv-inspect&lt;/code&gt; already exists for profiling a column's inferred type. This tool's job is the shape of the difference, not the meaning of the cell — but that boundary is worth stating on the tin, and right now it is not stated.&lt;/p&gt;

&lt;p&gt;Repo: &lt;a href="https://github.com/Raknaos/csv-key-diff" rel="noopener noreferrer"&gt;https://github.com/Raknaos/csv-key-diff&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-sO&lt;/span&gt; https://raw.githubusercontent.com/Raknaos/csv-key-diff/main/csv_key_diff.py
python3 csv_key_diff.py old.csv new.csv &lt;span class="nt"&gt;--key&lt;/span&gt; &lt;span class="nb"&gt;id&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it on two files whose answer you already know. If &lt;code&gt;added&lt;/code&gt; lists rows that only exist in your second file, you have found the orientation on your own, and from then on you will read every output from this tool with the argument order in your head — which is the only defence that works when a tool's naming disagrees with its behaviour.&lt;/p&gt;

</description>
      <category>debugging</category>
      <category>python</category>
      <category>testing</category>
      <category>tools</category>
    </item>
    <item>
      <title>My wait-for-it wrapper reported success for a port that never opened</title>
      <dc:creator>Raknaos</dc:creator>
      <pubDate>Mon, 14 Sep 2026 09:13:55 +0000</pubDate>
      <link>https://dev.to/raknaos/my-wait-for-it-wrapper-reported-success-for-a-port-that-never-opened-ga3</link>
      <guid>https://dev.to/raknaos/my-wait-for-it-wrapper-reported-success-for-a-port-that-never-opened-ga3</guid>
      <description>&lt;p&gt;I had the usual line in an entrypoint script:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;./wait-for-it.sh db:5432 &lt;span class="nt"&gt;--&lt;/span&gt; python app.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The container exited 0. Then the application died three seconds later with&lt;br&gt;
&lt;code&gt;connection refused&lt;/code&gt;, and I spent an afternoon blaming the database image.&lt;/p&gt;

&lt;p&gt;The database never came up. &lt;code&gt;wait-for-it.sh&lt;/code&gt; knew that. It printed its timeout&lt;br&gt;
warning, and then it ran my command anyway, and the exit code I checked was the&lt;br&gt;
exit code of &lt;code&gt;python app.py&lt;/code&gt; starting up successfully — which it did, for about&lt;br&gt;
three seconds.&lt;/p&gt;

&lt;p&gt;I want to be precise about what is a bug and what is a design decision. This one&lt;br&gt;
is a design decision, it is documented, and it bit me anyway because the&lt;br&gt;
documented behaviour is the opposite of what the name of the tool promises.&lt;/p&gt;
&lt;h2&gt;
  
  
  What the script actually does at the end
&lt;/h2&gt;

&lt;p&gt;The repo is &lt;a href="https://github.com/Raknaos/wait-for-it" rel="noopener noreferrer"&gt;https://github.com/Raknaos/wait-for-it&lt;/a&gt; — a revived copy of&lt;br&gt;
vishnubob's original, MIT licensed, upstream history preserved. The whole tool is&lt;br&gt;
one file of 182 lines of bash. Here is its final block, verbatim:&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="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[[&lt;/span&gt; &lt;span class="nv"&gt;$WAITFORIT_CLI&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="s2"&gt;""&lt;/span&gt; &lt;span class="o"&gt;]]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    if&lt;/span&gt; &lt;span class="o"&gt;[[&lt;/span&gt; &lt;span class="nv"&gt;$WAITFORIT_RESULT&lt;/span&gt; &lt;span class="nt"&gt;-ne&lt;/span&gt; 0 &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nv"&gt;$WAITFORIT_STRICT&lt;/span&gt; &lt;span class="nt"&gt;-eq&lt;/span&gt; 1 &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;echoerr &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$WAITFORIT_cmdname&lt;/span&gt;&lt;span class="s2"&gt;: strict mode, refusing to execute subprocess"&lt;/span&gt;
        &lt;span class="nb"&gt;exit&lt;/span&gt; &lt;span class="nv"&gt;$WAITFORIT_RESULT&lt;/span&gt;
    &lt;span class="k"&gt;fi
    &lt;/span&gt;&lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;WAITFORIT_CLI&lt;/span&gt;&lt;span class="p"&gt;[@]&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;else
    &lt;/span&gt;&lt;span class="nb"&gt;exit&lt;/span&gt; &lt;span class="nv"&gt;$WAITFORIT_RESULT&lt;/span&gt;
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read the condition again. Refusing to run the command requires &lt;strong&gt;both&lt;/strong&gt; a failed&lt;br&gt;
wait &lt;strong&gt;and&lt;/strong&gt; &lt;code&gt;-s&lt;/code&gt;. Without &lt;code&gt;-s&lt;/code&gt;, a failed wait falls through to &lt;code&gt;exec&lt;/code&gt;. And&lt;br&gt;
because it is &lt;code&gt;exec&lt;/code&gt;, the script's own exit status is replaced by the child's, so&lt;br&gt;
the timeout leaves no trace in the return code at all.&lt;/p&gt;

&lt;p&gt;I ran it on my machine today to have the numbers instead of my memory:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ ./wait-for-it.sh 127.0.0.1:1 -t 5
wait-for-it.sh: waiting 5 seconds for 127.0.0.1:1
wait-for-it.sh: timeout occurred after waiting 5 seconds for 127.0.0.1:1
$ echo $?
124

$ ./wait-for-it.sh 127.0.0.1:1 -t 2 -- echo "RAN ANYWAY"
wait-for-it.sh: waiting 2 seconds for 127.0.0.1:1
wait-for-it.sh: timeout occurred after waiting 2 seconds for 127.0.0.1:1
RAN ANYWAY
$ echo $?
0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That second run is the entire story. Nothing opened on port 1, the script said so&lt;br&gt;
out loud, and the process still reported success. Exit 124 is the coreutils&lt;br&gt;
&lt;code&gt;timeout&lt;/code&gt; status leaking through — the script re-invokes itself under &lt;code&gt;timeout&lt;/code&gt;&lt;br&gt;
so that Ctrl-C works during a wait, which is a genuinely clever piece of bash and&lt;br&gt;
is why you see 124 rather than 1.&lt;/p&gt;

&lt;p&gt;So the rule is: &lt;strong&gt;&lt;code&gt;wait-for-it.sh&lt;/code&gt; without &lt;code&gt;-s&lt;/code&gt; is not a check. It is a delay with&lt;br&gt;
a ceiling.&lt;/strong&gt; If you want a gate, the flag is not optional.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why I picked up the repo instead of rewriting it
&lt;/h2&gt;

&lt;p&gt;Because the probe itself is the interesting part and a rewrite would have lost 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="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[[&lt;/span&gt; &lt;span class="nv"&gt;$WAITFORIT_ISBUSY&lt;/span&gt; &lt;span class="nt"&gt;-eq&lt;/span&gt; 1 &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;nc &lt;span class="nt"&gt;-z&lt;/span&gt; &lt;span class="nv"&gt;$WAITFORIT_HOST&lt;/span&gt; &lt;span class="nv"&gt;$WAITFORIT_PORT&lt;/span&gt;
    &lt;span class="nv"&gt;WAITFORIT_result&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$?&lt;/span&gt;
&lt;span class="k"&gt;else&lt;/span&gt;
    &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /dev/tcp/&lt;span class="nv"&gt;$WAITFORIT_HOST&lt;/span&gt;/&lt;span class="nv"&gt;$WAITFORIT_PORT&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null 2&amp;gt;&amp;amp;1
    &lt;span class="nv"&gt;WAITFORIT_result&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$?&lt;/span&gt;
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;/dev/tcp&lt;/code&gt; is a bash pseudo-device, not a file. It opens a TCP connection with no&lt;br&gt;
binary to install, which is why this works in a scratch container that has bash&lt;br&gt;
and nothing else — no &lt;code&gt;netcat&lt;/code&gt;, no &lt;code&gt;curl&lt;/code&gt;, no Python. The &lt;code&gt;ISBUSY&lt;/code&gt; branch exists&lt;br&gt;
because Alpine ships busybox, whose &lt;code&gt;timeout&lt;/code&gt; does not always accept the same&lt;br&gt;
flags, so the script resolves its own &lt;code&gt;timeout&lt;/code&gt; through &lt;code&gt;realpath&lt;/code&gt; and checks the&lt;br&gt;
path for the string &lt;code&gt;busybox&lt;/code&gt;. Two environment differences, handled with four&lt;br&gt;
lines. That is the whole argument for pure bash here.&lt;/p&gt;

&lt;p&gt;What the revival changes is the test situation. Upstream ships a &lt;code&gt;test/&lt;/code&gt;&lt;br&gt;
directory: &lt;code&gt;wait-for-it.py&lt;/code&gt;, &lt;code&gt;container-runners.py&lt;/code&gt; and a &lt;code&gt;requirements.txt&lt;/code&gt;&lt;br&gt;
pinning &lt;code&gt;docker&amp;gt;=4.0.0&lt;/code&gt; and &lt;code&gt;parameterized&amp;gt;=0.7.0&lt;/code&gt;. The runners spin up four base&lt;br&gt;
images — two Debian-python tags and two Alpine/busybox combinations — to exercise&lt;br&gt;
the busybox branch. In a repo whose whole point is "no dependencies outside the&lt;br&gt;
shell", a test suite that needs Docker and two pip packages is a strange fit. The&lt;br&gt;
revived repo drops that directory and replaces it with &lt;code&gt;test_wait_for_it.py&lt;/code&gt; at&lt;br&gt;
the root: 47 lines, stdlib only, two tests. The first binds an ephemeral loopback&lt;br&gt;
port, runs &lt;code&gt;wait-for-it.sh&lt;/code&gt; against it and asserts exit 0 plus the child&lt;br&gt;
command's output. The second opens a socket to reserve a port, closes it, and&lt;br&gt;
asserts a non-zero exit — under &lt;code&gt;--strict&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The script itself is untouched: &lt;code&gt;wait-for-it.sh&lt;/code&gt; in this repo is byte-identical to&lt;br&gt;
upstream's, all 182 lines. That is deliberate.&lt;/p&gt;

&lt;p&gt;And that last detail about the suite is the one I keep turning over. The only&lt;br&gt;
failure case it asserts is the strict one. The default, non-strict,&lt;br&gt;
run-the-command-anywhere path is untested — not because someone forgot, but&lt;br&gt;
because as far as the script is concerned it is not a failure path. I left it&lt;br&gt;
that way. Covering it would mean asserting that a timed-out wait still runs your&lt;br&gt;
command, which freezes the behaviour that surprised me into a contract.&lt;/p&gt;
&lt;h2&gt;
  
  
  What it costs, and what it will not tell you
&lt;/h2&gt;

&lt;p&gt;Being honest about the sharp edges I hit while measuring:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One second, minimum.&lt;/strong&gt; The loop ends in &lt;code&gt;sleep 1&lt;/code&gt;. A service that is ready 200 ms&lt;br&gt;
after a probe still waits 800 ms. On a laptop, fine. In front of a &lt;code&gt;docker-compose&lt;br&gt;
up&lt;/code&gt; that gates a whole dependency graph, you pay it once per dependency.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A name that cannot resolve looks exactly like a port that is closed.&lt;/strong&gt; I pointed&lt;br&gt;
it at &lt;code&gt;no-such-host-raknaos-test.invalid:80&lt;/code&gt; with &lt;code&gt;-t 3&lt;/code&gt;. Output:&lt;br&gt;
&lt;code&gt;timeout occurred after waiting 3 seconds&lt;/code&gt;. Exit 124. No hint that DNS was the&lt;br&gt;
problem. The probe discards bash's error text into &lt;code&gt;/dev/null&lt;/code&gt;, so a typo in your&lt;br&gt;
service name and a database still booting are the same event.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Service names in the port field do not work.&lt;/strong&gt; &lt;code&gt;127.0.0.1:http&lt;/code&gt; — where &lt;code&gt;/etc/services&lt;/code&gt;&lt;br&gt;
says port 80 — also just times out. The port goes into &lt;code&gt;/dev/tcp&lt;/code&gt; as written.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;IPv6 literals are mangled, silently.&lt;/strong&gt; This is the one I would call a defect:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ ./wait-for-it.sh ::1:80 -t 2
wait-for-it.sh: waiting 2 seconds for 1:80
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Argument parsing matches &lt;code&gt;*:*&lt;/code&gt; and splits on &lt;code&gt;:&lt;/code&gt;, so &lt;code&gt;::1:80&lt;/code&gt; does not survive the&lt;br&gt;
trip: the host became &lt;code&gt;1&lt;/code&gt;. The script then waits for a host named &lt;code&gt;1&lt;/code&gt; for as long&lt;br&gt;
as you let it. On a dual-stack network where your database only has an AAAA&lt;br&gt;
record, this fails in the least helpful way available.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The default timeout is 15 seconds and it is not announced until it fires.&lt;/strong&gt;&lt;br&gt;
&lt;code&gt;WAITFORIT_TIMEOUT=${WAITFORIT_TIMEOUT:-15}&lt;/code&gt;. Run without &lt;code&gt;-t&lt;/code&gt; on a closed port and&lt;br&gt;
you wait a quarter of a minute, which is short enough to look like the script&lt;br&gt;
succeeded and long enough to hide inside a slow build.&lt;/p&gt;
&lt;h2&gt;
  
  
  Use it, with the flag
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/Raknaos/wait-for-it
&lt;span class="nb"&gt;cd &lt;/span&gt;wait-for-it &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; python3 test_wait_for_it.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;&lt;code&gt;ALL TESTS PASSED&lt;/code&gt; on my machine, no dependencies beyond Python and bash. Then put&lt;br&gt;
&lt;code&gt;-s&lt;/code&gt; in your entrypoint and decide what you want to happen when the dependency&lt;br&gt;
loses the race:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;./wait-for-it.sh db:5432 &lt;span class="nt"&gt;--strict&lt;/span&gt; &lt;span class="nt"&gt;-t&lt;/span&gt; 60 &lt;span class="nt"&gt;--&lt;/span&gt; python app.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now a dead database stops the container at the gate with exit 124 instead of&lt;br&gt;
starting an application that has nothing to talk to. If your orchestrator treats&lt;br&gt;
a non-zero exit as "restart me", the restart loop is what you wanted in the first&lt;br&gt;
place. The tool is 182 lines of bash, and the whole lesson fits in one flag, which&lt;br&gt;
is why it took me a wasted afternoon to learn it.&lt;/p&gt;

</description>
      <category>bash</category>
      <category>debugging</category>
      <category>devops</category>
      <category>docker</category>
    </item>
    <item>
      <title>My JSONL tool printed null for a broken line and nothing in my pipeline noticed</title>
      <dc:creator>Raknaos</dc:creator>
      <pubDate>Sun, 13 Sep 2026 09:13:17 +0000</pubDate>
      <link>https://dev.to/raknaos/my-jsonl-tool-printed-null-for-a-broken-line-and-nothing-in-my-pipeline-noticed-2h0d</link>
      <guid>https://dev.to/raknaos/my-jsonl-tool-printed-null-for-a-broken-line-and-nothing-in-my-pipeline-noticed-2h0d</guid>
      <description>&lt;p&gt;I put a JSON Lines parser in front of a pipeline that reads export dumps from other people's&lt;br&gt;
services. Some of those dumps are not clean. Records get truncated by a proxy, a log shipper&lt;br&gt;
writes half a line and dies, someone opens the file in a spreadsheet app and saves it back with&lt;br&gt;
a stray tab. The failure I cared about was not "the file has one broken line" - it was "the file&lt;br&gt;
has one broken line and my pipeline finished with exit code 0".&lt;/p&gt;

&lt;p&gt;That is the exact behaviour the first version of jsonl-cli had, and I did not catch it in review.&lt;br&gt;
I caught it because I ran the tool on a real export instead of on the fixture.&lt;/p&gt;
&lt;h2&gt;
  
  
  What the tool is
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/Raknaos/jsonl-cli" rel="noopener noreferrer"&gt;jsonl-cli&lt;/a&gt; is a single-file, stdlib-only Python 3 CLI for&lt;br&gt;
JSON Lines (&lt;code&gt;.jsonl&lt;/code&gt;) streams: &lt;code&gt;validate&lt;/code&gt;, &lt;code&gt;count&lt;/code&gt;, &lt;code&gt;get&lt;/code&gt;, &lt;code&gt;pretty&lt;/code&gt;. All four take a path or &lt;code&gt;-&lt;/code&gt;&lt;br&gt;
for stdin, and &lt;code&gt;get&lt;/code&gt; resolves a dotted path into nested structures - &lt;code&gt;user.profile.id&lt;/code&gt; for&lt;br&gt;
objects, numeric steps for arrays, so &lt;code&gt;user.roles.0&lt;/code&gt; works. It is the first tool in the Raknaos&lt;br&gt;
Tools Lab collection, published 08-09-2026, MIT, no third-party imports. The whole point of the&lt;br&gt;
lab is tools you can drop onto a machine with &lt;code&gt;curl&lt;/code&gt; and nothing else.&lt;/p&gt;

&lt;p&gt;Here is the pipeline shape that broke the illusion, run against a four-record file with one&lt;br&gt;
corrupt line:&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;python3 jsonl_cli.py get user.email data.jsonl
&lt;span class="s2"&gt;"a@x.io"&lt;/span&gt;
null
null
&lt;span class="s2"&gt;"b@x.io"&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four valid emails would have printed four quoted strings. I got two quoted strings and two&lt;br&gt;
&lt;code&gt;null&lt;/code&gt;s and a clean exit. Reading that output as a human, I have no idea which of the two &lt;code&gt;null&lt;/code&gt;&lt;br&gt;
lines were records that genuinely had no email and which were records that never parsed at all.&lt;br&gt;
Reading it as a shell, the situation is worse: exit 0 means "nothing to see", so the job that&lt;br&gt;
wraps this command reports success and the count quietly drops.&lt;/p&gt;
&lt;h2&gt;
  
  
  The two errors are not the same error
&lt;/h2&gt;

&lt;p&gt;The root cause was the shape of the loop: the parse, the field lookup and the print all sat&lt;br&gt;
inside one &lt;code&gt;try&lt;/code&gt;, and the handler printed a null.&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="c1"&gt;# first release
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;cmd_get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;_open_input&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;file&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;line_str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;line_str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;continue&lt;/span&gt;
            &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;obj&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;line_str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="n"&gt;val&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_resolve_dotted_key&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;val&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
            &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;null&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;return 0&lt;/code&gt; is unconditional, so the function cannot report a problem even if it wanted to. And&lt;br&gt;
&lt;code&gt;print("null")&lt;/code&gt; conflates two completely different facts:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The line parsed fine and the path is absent in it. That is data. The answer to "what is
&lt;code&gt;user.email&lt;/code&gt; in this record" genuinely is null, and the pipeline should carry on.&lt;/li&gt;
&lt;li&gt;The line is not JSON. That is an input error. Silently turning it into the same null as case
one destroys the only signal that anything went wrong.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A &lt;code&gt;null&lt;/code&gt; on stdout is a value. A corrupt line is not a value, it is a reason to distrust the rest&lt;br&gt;
of the output. The second commit fixes exactly that separation.&lt;/p&gt;
&lt;h2&gt;
  
  
  What it does now
&lt;/h2&gt;

&lt;p&gt;Invalid lines go to stderr with their line number, the offending line is skipped rather than&lt;br&gt;
faked into a value, and the exit code flips to 1. A missing key still prints &lt;code&gt;null&lt;/code&gt; and still&lt;br&gt;
exits 0, because that is a legitimate answer:&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="c1"&gt;# current
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;cmd_get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;had_invalid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;_open_input&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;file&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;line_no&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;enumerate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="n"&gt;line_str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;line_str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;continue&lt;/span&gt;
            &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;obj&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;line_str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="c1"&gt;# an invalid JSON line is an input error, NOT a null value:
&lt;/span&gt;                &lt;span class="c1"&gt;# report it on stderr and flip the exit code so pipelines notice
&lt;/span&gt;                &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stderr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Line &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;line_no&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: invalid JSON - &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="n"&gt;had_invalid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
                &lt;span class="k"&gt;continue&lt;/span&gt;
            &lt;span class="n"&gt;val&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_resolve_dotted_key&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;val&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;had_invalid&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same file as before, same command:&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;python3 jsonl_cli.py get user.email data.jsonl
Line 3: invalid JSON - Expecting value: line 1 column 1 &lt;span class="o"&gt;(&lt;/span&gt;char 0&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="s2"&gt;"a@x.io"&lt;/span&gt;
null
&lt;span class="s2"&gt;"b@x.io"&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;
1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three values on stdout instead of four, which is the honest answer: one of those four lines is&lt;br&gt;
not a record. &lt;code&gt;pretty&lt;/code&gt; got the same treatment, and &lt;code&gt;count&lt;/code&gt; picked up a guard on&lt;br&gt;
&lt;code&gt;--valid-only --invalid-only&lt;/code&gt; - the first release accepted both flags and quietly let one win,&lt;br&gt;
which is a 2 (usage error), not a 0. The exit-code contract is now written in the module&lt;br&gt;
docstring: 0 clean, 1 at least one invalid input line, 2 usage error.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it does not do
&lt;/h2&gt;

&lt;p&gt;Honesty about the edge, because it will bite you:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Exit 1 is not a per-line contract.&lt;/strong&gt; It says "at least one input line was bad somewhere in
this stream". It does not tell you how many, and it is the same code whether one line out of a
million failed or every line failed. If your pipeline needs the distinction, use
&lt;code&gt;count --invalid-only&lt;/code&gt; and branch on the number, not on the status.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A missing key is silent by design.&lt;/strong&gt; &lt;code&gt;get user.profile.id&lt;/code&gt; on a record without a &lt;code&gt;profile&lt;/code&gt;
prints &lt;code&gt;null&lt;/code&gt; and exits 0. There is no flag that turns absent-path into an error, so a typo in
your key path looks exactly like a record that lacks the field. &lt;code&gt;validate&lt;/code&gt; will not help you
either - it only checks that each line parses, never that a key exists.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nothing is type-checked.&lt;/strong&gt; &lt;code&gt;get&lt;/code&gt; returns whatever is at the path, JSON-encoded: a string, an
object, an array, a number. If you need the value usable downstream you parse it again.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Blank lines are skipped, not reported&lt;/strong&gt;, in every subcommand. A file that lost half its
records to a &lt;code&gt;grep -v&lt;/code&gt; accident is not something this tool looks for.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;stdin is read as text, one line at a time.&lt;/strong&gt; There is no buffering of the whole stream, which
is what makes it usable on a growing log, and there is no encoding auto-detection beyond
&lt;code&gt;open(path, "r", encoding="utf-8")&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;BrokenPipeError&lt;/code&gt; in &lt;code&gt;main()&lt;/code&gt; maps to &lt;code&gt;sys.exit(0)&lt;/code&gt;. That is deliberate - &lt;code&gt;jsonl get k f |
head -1&lt;/code&gt; should not report failure because &lt;code&gt;head&lt;/code&gt; stopped reading - but it does mean the
exit code of a truncated pipeline is not meaningful. Check the status of the command you
actually care about.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Trying it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-sSL&lt;/span&gt; https://raw.githubusercontent.com/Raknaos/jsonl-cli/main/jsonl_cli.py &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; jsonl
&lt;span class="nb"&gt;chmod&lt;/span&gt; +x jsonl
./jsonl get user.email - &amp;lt; data.jsonl
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Repo: &lt;a href="https://github.com/Raknaos/jsonl-cli" rel="noopener noreferrer"&gt;https://github.com/Raknaos/jsonl-cli&lt;/a&gt; - &lt;code&gt;jsonl_cli.py&lt;/code&gt;, &lt;code&gt;test_jsonl_cli.py&lt;/code&gt;, MIT, Python 3.8&lt;br&gt;
and up, standard library only. The unit tests run under &lt;code&gt;python3 -m unittest discover&lt;/code&gt; with no&lt;br&gt;
install step, and the CI job for the repo is that one line.&lt;/p&gt;

</description>
      <category>cli</category>
      <category>json</category>
      <category>python</category>
      <category>tools</category>
    </item>
    <item>
      <title>A Pidfile Lock Is Only as Good as Its Stale Check</title>
      <dc:creator>Raknaos</dc:creator>
      <pubDate>Sat, 12 Sep 2026 09:08:03 +0000</pubDate>
      <link>https://dev.to/raknaos/a-pidfile-lock-is-only-as-good-as-its-stale-check-3j26</link>
      <guid>https://dev.to/raknaos/a-pidfile-lock-is-only-as-good-as-its-stale-check-3j26</guid>
      <description>&lt;p&gt;Every backup script eventually tells the same story. A cron job ran long, the&lt;br&gt;
machine rebooted mid-run, and some morning I noticed the nightly job had quietly&lt;br&gt;
stopped firing for three days. Nothing had crashed — the opposite. The lock file&lt;br&gt;
the wrapper creates on startup was still sitting in &lt;code&gt;/tmp&lt;/code&gt;, holding a PID from a&lt;br&gt;
boot that no longer existed, and every subsequent run politely refused to start.&lt;/p&gt;

&lt;p&gt;That's the trap with pidfile locking: the lock protects you from overlap, but a&lt;br&gt;
dead lock protects you from &lt;em&gt;everything&lt;/em&gt;, including the job itself. I wrote&lt;br&gt;
&lt;code&gt;pidlock&lt;/code&gt; (&lt;a href="https://github.com/Raknaos/pidlock" rel="noopener noreferrer"&gt;https://github.com/Raknaos/pidlock&lt;/a&gt;), a zero-dependency Python CLI that&lt;br&gt;
wraps a command in a pidfile and handles this case, and the interesting part&lt;br&gt;
isn't the wrapper — it's what "is this process actually alive?" really means when&lt;br&gt;
you answer it once per run for a year.&lt;/p&gt;
&lt;h2&gt;
  
  
  The mechanism
&lt;/h2&gt;

&lt;p&gt;Acquisition is a single atomic create. No lock daemon, no &lt;code&gt;flock&lt;/code&gt;, no parsing:&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;def&lt;/span&gt; &lt;span class="nf"&gt;acquire&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Try to create the pidfile atomically. Returns True on success.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;fd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;fd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;O_CREAT&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;O_EXCL&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;O_WRONLY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mo"&gt;0o644&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;%d&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getpid&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;FileExistsError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;OSError&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;errno&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;errno&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;EEXIST&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt;
    &lt;span class="k"&gt;finally&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;fd&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fd&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;O_CREAT | O_EXCL&lt;/code&gt; guarantees two racing processes never both succeed — the&lt;br&gt;
kernel picks one, the other gets &lt;code&gt;EEXIST&lt;/code&gt;. That half is easy. The hard half is&lt;br&gt;
what you do &lt;em&gt;after&lt;/em&gt; the &lt;code&gt;EEXIST&lt;/code&gt;, because that's where every stale-lock bug lives.&lt;/p&gt;

&lt;p&gt;When acquisition fails, &lt;code&gt;pidlock&lt;/code&gt; reads the file, checks the PID, and only&lt;br&gt;
removes it if it's &lt;em&gt;confirmed dead&lt;/em&gt; — a corrupt or unreadable pidfile counts as&lt;br&gt;
dead, a live one never does:&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;def&lt;/span&gt; &lt;span class="nf"&gt;pid_alive&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pid&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;pid&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;kill&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;ProcessLookupError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;PermissionError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;OSError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;acquire_with_stale_retry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;acquire&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;is_stale&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;unlink&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;OSError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;pass&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;acquire&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details in there that I'd have gotten wrong the first time I needed them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;PermissionError&lt;/code&gt; returns &lt;code&gt;True&lt;/code&gt;.&lt;/strong&gt; On Linux, a signal probe on a process owned&lt;br&gt;
by &lt;em&gt;another user&lt;/em&gt; fails with &lt;code&gt;EPERM&lt;/code&gt;, not &lt;code&gt;ESRCH&lt;/code&gt;. &lt;code&gt;pid 4231 is alive&lt;/code&gt; is the&lt;br&gt;
correct answer even though the probe failed — deleting that pidfile would kill&lt;br&gt;
another user's job's lock. The asymmetry is deliberate: never delete a lock&lt;br&gt;
unless you're sure the owner is gone.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Release verifies ownership before unlinking.&lt;/strong&gt; If your lock was evicted as&lt;br&gt;
stale while your process ran (see below), and a &lt;em&gt;new&lt;/em&gt; instance already wrote its&lt;br&gt;
own PID into the same path, an unconditional &lt;code&gt;os.unlink&lt;/code&gt; at exit deletes the&lt;br&gt;
wrong lock. So &lt;code&gt;release_if_ours&lt;/code&gt; re-reads the file and unlinks only if it still&lt;br&gt;
contains its own &lt;code&gt;os.getpid()&lt;/code&gt;. Seven lines, invisible when it works, load-bearing&lt;br&gt;
when it doesn't.&lt;/p&gt;
&lt;h2&gt;
  
  
  What a live PID doesn't prove
&lt;/h2&gt;

&lt;p&gt;Here's the part I had to state out loud once the reboot incident was explained:&lt;br&gt;
&lt;code&gt;os.kill(pid, 0)&lt;/code&gt; proves &lt;em&gt;some&lt;/em&gt; process owns that number. It does not prove it's&lt;br&gt;
the process that wrote the file.&lt;/p&gt;

&lt;p&gt;PIDs are recycled. After a reboot, the counter restarts from the top — a fresh&lt;br&gt;
&lt;code&gt;rsyslogd&lt;/code&gt; can inherit the exact number your stale backup wrote yesterday. The&lt;br&gt;
&lt;code&gt;pid_alive&lt;/code&gt; check says "alive", the stale-lock logic says "not my problem", and&lt;br&gt;
your backup stays locked out indefinitely by a process that has nothing to do&lt;br&gt;
with it. The textbook fix is to store a signature alongside the PID — the&lt;br&gt;
process &lt;code&gt;starttime&lt;/code&gt; field from &lt;code&gt;/proc/&amp;lt;pid&amp;gt;/stat&lt;/code&gt;, or a hash of &lt;code&gt;cmdline&lt;/code&gt; — and&lt;br&gt;
verify it matches before trusting liveness.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;pidlock&lt;/code&gt; does not do this. The pidfile contains one number, full stop. That's a&lt;br&gt;
decision, not an oversight, and it comes with the rest of what the tool refuses&lt;br&gt;
to be:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Advisory only.&lt;/strong&gt; A job that never calls &lt;code&gt;pidlock&lt;/code&gt; doesn't see the lock. The
README says so in one line: only processes that use pidlock respect it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No &lt;code&gt;flock&lt;/code&gt;.&lt;/strong&gt; A kernel &lt;code&gt;flock&lt;/code&gt; dies with its holder — no stale state is even
possible across a reboot — but it doesn't play well with NFS, and &lt;code&gt;flock(2)&lt;/code&gt;
availability varies enough across platforms that the CLI stays portable. The
README lists this under "What it does NOT do" rather than pretending.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TOCTOU between the staleness check and the unlink is real and documented.&lt;/strong&gt;
Two processes can both observe a stale lock at once and both race to evict it;
the &lt;code&gt;O_EXCL&lt;/code&gt; re-acquire still lets only one win, but the losing side has
already deleted a file it thought was stale. &lt;code&gt;--wait N&lt;/code&gt; (retry acquisition for
N seconds) shrinks the practical blast radius for cron-style collisions.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The PID-recycle false-positive needs a reboot &lt;em&gt;plus&lt;/em&gt; an unlucky PID collision on&lt;br&gt;
a busy-enough box to matter — rare in practice, and rarer still for short-lived&lt;br&gt;
backup jobs whose stale window is hours, not weeks. On machines where the&lt;br&gt;
consequence justifies the coupling, &lt;code&gt;flock&lt;/code&gt; or a systemd &lt;code&gt;PathExists=&lt;/code&gt; guard is&lt;br&gt;
the honest recommendation, and I'd rather say that in the README than have you&lt;br&gt;
discover it during an outage. Same discipline for the check against the&lt;br&gt;
&lt;code&gt;--check&lt;/code&gt; flag: it reports the state it can observe, and where the staleness&lt;br&gt;
signal is ambiguous on a recycled PID it stays conservative rather than&lt;br&gt;
destructive.&lt;/p&gt;
&lt;h2&gt;
  
  
  Trying it
&lt;/h2&gt;

&lt;p&gt;Wrap any command; the exit code propagates, and signals clean up after&lt;br&gt;
themselves (130 on SIGINT, 143 on SIGTERM):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pidlock &lt;span class="nt"&gt;--pidfile&lt;/span&gt; /tmp/backup.pid &lt;span class="nt"&gt;--&lt;/span&gt; /home/me/backup.sh
pidlock &lt;span class="nt"&gt;--pidfile&lt;/span&gt; /tmp/backup.pid &lt;span class="nt"&gt;--wait&lt;/span&gt; 60 &lt;span class="nt"&gt;--&lt;/span&gt; ./backup.sh   &lt;span class="c"&gt;# wait instead of fail&lt;/span&gt;
pidlock &lt;span class="nt"&gt;--pidfile&lt;/span&gt; /tmp/backup.pid &lt;span class="nt"&gt;--check&lt;/span&gt;                     &lt;span class="c"&gt;# 0 free, 1 held&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No dependencies, Python 3.6+, 201 lines in the single &lt;code&gt;pidlock.py&lt;/code&gt;, and&lt;br&gt;
&lt;code&gt;--self-test&lt;/code&gt; proves the semantics hermetically (live pid blocks, dead pid is&lt;br&gt;
evicted and retried) without touching your real cron.&lt;/p&gt;

&lt;p&gt;Repo: &lt;a href="https://github.com/Raknaos/pidlock" rel="noopener noreferrer"&gt;https://github.com/Raknaos/pidlock&lt;/a&gt;&lt;/p&gt;

</description>
      <category>automation</category>
      <category>cli</category>
      <category>python</category>
    </item>
    <item>
      <title>Headless browser cookies are scoped to the CDP connection, not the browser</title>
      <dc:creator>Raknaos</dc:creator>
      <pubDate>Sat, 12 Sep 2026 06:07:46 +0000</pubDate>
      <link>https://dev.to/raknaos/headless-browser-cookies-are-scoped-to-the-cdp-connection-not-the-browser-24fl</link>
      <guid>https://dev.to/raknaos/headless-browser-cookies-are-scoped-to-the-cdp-connection-not-the-browser-24fl</guid>
      <description>&lt;p&gt;I sync my logged-in sessions from my desktop browser into a headless browser on a VPS, so that automation agents running there stay authenticated. The sync reported success: four cookies injected, matching names and domains. Then the headless browser opened the target site and it said logged-out.&lt;/p&gt;

&lt;p&gt;The cookies were in the jar. The page just could not see them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The actual rule
&lt;/h2&gt;

&lt;p&gt;The headless browser I use is Lightpanda, a headless browser written from scratch in Zig (V8 for JavaScript, libcurl for networking), driven over the Chrome DevTools Protocol. Its cookie jar is scoped &lt;strong&gt;per CDP connection&lt;/strong&gt;, not per browser process. Inject cookies over connection A, navigate and read the page over connection B, and B sees an empty jar. Nothing is wrong with the cookies; they simply live in the jar that belongs to A.&lt;/p&gt;

&lt;p&gt;This bit me for real on dev.to. My first architecture had every automation process open its own WebSocket to the browser and run its own &lt;code&gt;Network.setCookies&lt;/code&gt; followed by navigation. Each process saw its own injected session fine, and every other process saw nothing. Worse, two agents sharing one WebSocket would interleave commands, so a navigate from one could land between another's inject and evaluate.&lt;/p&gt;

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

&lt;p&gt;The relay I built now owns &lt;strong&gt;the only&lt;/strong&gt; CDP connection for its whole lifetime. Agents talk to it over plain HTTP on loopback:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;POST /v1/cdp&lt;/code&gt; proxies a CDP command onto that one connection, so every command — inject, navigate, evaluate — executes against the jar that actually holds the sessions.&lt;/li&gt;
&lt;li&gt;Processes never touch the WebSocket, so there is nothing to interleave; the relay serializes on its side.&lt;/li&gt;
&lt;li&gt;If the browser restarts, the connection dies and so would the jar. The relay keeps the last synced session in memory and replays it (&lt;code&gt;Network.setCookies&lt;/code&gt; again) on the fresh connection as soon as it is back.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last part matters more than it sounds: a headless browser crashing mid-day should not log out every session it held. Since the jar is per-connection, "persist sessions" really means "re-inject on every new connection", and it is the component that owns connections that has to do it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The second trap: &lt;code&gt;expires&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;While fixing this I hit a smaller one worth passing on. Injecting a cookie with an &lt;code&gt;expires&lt;/code&gt; attribute was silently discarded — not rejected, just dropped. The call returned success, the cookie never existed. Session cookies without &lt;code&gt;expires&lt;/code&gt; went through fine. The workaround is to strip &lt;code&gt;expires&lt;/code&gt; on injection and let the runtime jar treat them as session cookies; whatever persistence you need lives in your own snapshot of the sessions, which you re-import anyway for the reason above. Setting an explicit &lt;code&gt;Domain&lt;/code&gt;, &lt;code&gt;Secure&lt;/code&gt;, and &lt;code&gt;httpOnly&lt;/code&gt; with path &lt;code&gt;/&lt;/code&gt; also avoided a second class of silent mismatches.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;I had been thinking about browser state at the wrong granularity. "The browser has the cookies" is the wrong mental model for CDP-driven browsers — the &lt;strong&gt;connection&lt;/strong&gt; has the cookies, and anything that does not route through the connection that holds them is operating on an empty jar. If your automation stack has more than one component talking to the browser, decide once who owns the single connection and make everyone else go through it. It collapses a whole category of "works in process A, logged-out in process B" bugs, and it gives you one place to do session replay after a crash.&lt;/p&gt;

&lt;p&gt;The relay is about 1,200 lines of Python and lives with the rest of the project at &lt;a href="https://github.com/Raknaos/lightpanda-session-bridge" rel="noopener noreferrer"&gt;Raknaos/lightpanda-session-bridge&lt;/a&gt;. If you have ever lost an afternoon to a session that was injected but not visible, this is probably why.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>devtools</category>
      <category>automation</category>
      <category>headlessbrowsers</category>
    </item>
    <item>
      <title>My local repos were full of zombie branches and git never told me which ones were safe to delete</title>
      <dc:creator>Raknaos</dc:creator>
      <pubDate>Fri, 11 Sep 2026 11:03:15 +0000</pubDate>
      <link>https://dev.to/raknaos/my-local-repos-were-full-of-zombie-branches-and-git-never-told-me-which-ones-were-safe-to-delete-39fn</link>
      <guid>https://dev.to/raknaos/my-local-repos-were-full-of-zombie-branches-and-git-never-told-me-which-ones-were-safe-to-delete-39fn</guid>
      <description>&lt;p&gt;Last week I ran &lt;code&gt;git branch&lt;/code&gt; in one of my older working trees and got back forty-some local branches. Forty. I work on maybe three things at a time. The rest were residue: half of them were merged into &lt;code&gt;main&lt;/code&gt; months ago, a few tracked remote branches that someone had already deleted on the server, and the rest I genuinely did not recognize anymore.&lt;/p&gt;

&lt;p&gt;The problem with this pile is not disk space. A local branch ref is tiny. The problem is that it makes every other git command worse. &lt;code&gt;git log --all&lt;/code&gt; gets noisy. Tab completion for branch names becomes useless. And the moment you want to prune, you freeze, because deleting a branch you &lt;em&gt;think&lt;/em&gt; is dead is exactly the kind of operation that bites you six weeks later when you need a commit that only existed there.&lt;/p&gt;

&lt;p&gt;I tried the usual tricks. &lt;code&gt;git branch --merged main&lt;/code&gt; gives you a real signal, but it also happily lists your current branch, and if you pipe that straight into a delete you can shoot yourself in the foot. &lt;code&gt;git branch -vv&lt;/code&gt; shows &lt;code&gt;[gone]&lt;/code&gt; for branches whose upstream vanished, but you have to eyeball it across dozens of lines. And neither tool says anything at all about the branch you touched last in March and never came back to. There was no single command that gave me a &lt;em&gt;reason&lt;/em&gt; per branch. So I wrote one.&lt;/p&gt;

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

&lt;p&gt;&lt;code&gt;git-stale&lt;/code&gt; is a single Python file. The published &lt;code&gt;tool.py&lt;/code&gt; is 5,092 bytes and imports four things, all from the standard library: &lt;code&gt;argparse&lt;/code&gt;, &lt;code&gt;subprocess&lt;/code&gt;, &lt;code&gt;sys&lt;/code&gt;, and &lt;code&gt;datetime&lt;/code&gt;. No pip installs, no virtualenv, no version-pinning argument with a dependency that no longer exists. If you have Python 3.8 and &lt;code&gt;git&lt;/code&gt; on your &lt;code&gt;PATH&lt;/code&gt;, it runs. That was a deliberate constraint, not laziness: a cleanup tool that itself requires an install step is a tool I will not actually run, and a supply chain of one file has nothing to rot.&lt;/p&gt;

&lt;p&gt;The core idea is that a branch is stale if it matches &lt;em&gt;any&lt;/em&gt; of three independent checks, and the tool reports &lt;em&gt;which&lt;/em&gt; checks fired rather than a bare list:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;it is fully merged into the default branch (&lt;code&gt;main&lt;/code&gt; or &lt;code&gt;master&lt;/code&gt;),&lt;/li&gt;
&lt;li&gt;its upstream remote branch no longer exists (&lt;code&gt;[gone]&lt;/code&gt;),&lt;/li&gt;
&lt;li&gt;its last commit is older than a threshold (30 days by default).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The detection is thin wrapper-over-git on purpose. I did not reimplement merge-base logic or parse the index. I ask git itself, because git already knows the truth and I would only introduce a divergence bug by trying to agree with it:&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;def&lt;/span&gt; &lt;span class="nf"&gt;merged_branches&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;base&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cwd&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;run_git&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;branch&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--merged&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;base&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cwd&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;cwd&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;lstrip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;* &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;splitlines&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;remote_gone_branches&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cwd&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;gone&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;run_git&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;branch&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-vv&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cwd&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;cwd&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;splitlines&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[gone]&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;line&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;gone&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;lstrip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;* &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;gone&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;commit_date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;branch&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cwd&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;run_git&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;log&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--format=%ct&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;branch&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cwd&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;cwd&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromtimestamp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;tz&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;timezone&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;utc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;find_stale&lt;/code&gt; stitches the three together, and this is where the safety actually lives: the current branch and the default branch are skipped before a reason is ever attached. You will never see the branch you are standing on in the output.&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;def&lt;/span&gt; &lt;span class="nf"&gt;find_stale&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;days&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cwd&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;base&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;default_branch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cwd&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;cwd&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;cur&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;current_branch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cwd&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;cwd&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;merged&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;merged_branches&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;base&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cwd&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;cwd&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;gone&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;remote_gone_branches&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cwd&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;cwd&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;cutoff&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timezone&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;utc&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;days&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;86400&lt;/span&gt;
    &lt;span class="n"&gt;stale&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
    &lt;span class="n"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;run_git&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;branch&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--format=%(refname:short)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cwd&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;cwd&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;branch&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;splitlines&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="n"&gt;branch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;branch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;branch&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;branch&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;cur&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;branch&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;base&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="n"&gt;reasons&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;branch&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;merged&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;reasons&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;merged into &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;base&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;branch&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;gone&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;reasons&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;upstream gone&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;commit_date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;branch&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cwd&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;cwd&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;cutoff&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;reasons&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="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;untouched &amp;gt; &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;days&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;d&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;reasons&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;stale&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;branch&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;reasons&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;stale&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The default branch is not hard-coded. &lt;code&gt;default_branch()&lt;/code&gt; probes &lt;code&gt;refs/heads/main&lt;/code&gt;, falls back to &lt;code&gt;refs/heads/master&lt;/code&gt;, and the whole repo works the same on either name. Output is one line per branch with the reasons joined:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;feature/auth-redo: merged into main
wip/scraper-v2: upstream gone; untouched &amp;gt; 30d
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Running it with no flags is read-only. You see the list, you decide, nothing changes. Deletion is opt-in and, by default, asks you to confirm each branch one by one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python3 tool.py            &lt;span class="c"&gt;# just list&lt;/span&gt;
python3 tool.py &lt;span class="nt"&gt;--days&lt;/span&gt; 90  &lt;span class="c"&gt;# a looser age threshold&lt;/span&gt;
python3 tool.py &lt;span class="nt"&gt;--delete&lt;/span&gt;   &lt;span class="c"&gt;# confirm per branch&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The reason &lt;code&gt;--delete&lt;/code&gt; is separate from listing is behavioral, not technical. A tool that cleans up after you and a tool that cleans up &lt;em&gt;without asking&lt;/em&gt; are different products, and the second one you only appreciate after it deletes the wrong thing once.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it does not do
&lt;/h2&gt;

&lt;p&gt;Being straight about the boundaries matters more than the feature list here.&lt;/p&gt;

&lt;p&gt;Deletion uses &lt;code&gt;git branch -D&lt;/code&gt;, the force flag. That means it does &lt;strong&gt;not&lt;/strong&gt; re-verify that a branch is merged at the moment of deletion. It trusts the reasons it printed and trusts you to have read them. I made that choice so the tool can delete &lt;code&gt;[gone]&lt;/code&gt; and untouched branches that are legitimately unmerged but clearly abandoned, but the cost is real: read the reasons before you answer &lt;code&gt;y&lt;/code&gt;. The README says this explicitly rather than burying it.&lt;/p&gt;

&lt;p&gt;The age check keys off the branch tip's commit date. If you have a three-week-old branch you only push to once a quarter, it will show up at the default 30 days. That is a listing, not a deletion, and &lt;code&gt;--days&lt;/code&gt; is there for exactly that case.&lt;/p&gt;

&lt;p&gt;It reads local branch refs against your last-known view of the remote. If your remote-tracking refs are stale because you have not fetched, &lt;code&gt;[gone]&lt;/code&gt; detection is only as good as your last fetch. There is no silent &lt;code&gt;git fetch&lt;/code&gt; behind the scenes; the tool does not touch the network.&lt;/p&gt;

&lt;p&gt;It has no idea what your team's workflow is. A long-lived &lt;code&gt;release/*&lt;/code&gt; branch will happily be flagged as untouched. The tool surfaces candidates with reasons; the judgment call stays with you on purpose.&lt;/p&gt;

&lt;p&gt;And there is a self-test, because I did not want to ship a thing whose whole job is deleting refs based on my own confidence. It creates a throwaway repo in a &lt;code&gt;TemporaryDirectory&lt;/code&gt;, builds a known branch topology, and asserts that merged-into-&lt;code&gt;main&lt;/code&gt; shows up, that a fresh unmerged branch does &lt;em&gt;not&lt;/em&gt;, and that &lt;code&gt;main&lt;/code&gt; and the checked-out branch never appear:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python3 tool.py &lt;span class="nt"&gt;--self-test&lt;/span&gt;
&lt;span class="c"&gt;# self-test OK&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That runs in the CI alongside a fresh clone. If a future edit makes the tool report the branch you are standing on, the test fails and nothing gets published. I would rather catch that on a temporary directory than in someone's real working tree.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;If your &lt;code&gt;git branch&lt;/code&gt; output has drifted into archaeology, run it read-only first and just look at the reasons. It is one file you can read top to bottom before you let it near your repo.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/Raknaos/git-stale
&lt;span class="nb"&gt;cd &lt;/span&gt;git-stale
python3 tool.py &lt;span class="nt"&gt;--self-test&lt;/span&gt;   &lt;span class="c"&gt;# prove it to yourself first&lt;/span&gt;
python3 tool.py               &lt;span class="c"&gt;# in any repo you want to inspect&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The source is at &lt;a href="https://github.com/Raknaos/git-stale" rel="noopener noreferrer"&gt;https://github.com/Raknaos/git-stale&lt;/a&gt; (MIT). The whole thing lives in that single &lt;code&gt;tool.py&lt;/code&gt;; open it before you run it, and if the age default is wrong for how you work, change the number. The tool will not touch a branch you did not explicitly tell it to.&lt;/p&gt;

</description>
      <category>git</category>
      <category>productivity</category>
      <category>software</category>
    </item>
    <item>
      <title>Securing a loopback CDP relay with extension-ID pinning</title>
      <dc:creator>Raknaos</dc:creator>
      <pubDate>Thu, 10 Sep 2026 18:03:55 +0000</pubDate>
      <link>https://dev.to/raknaos/securing-a-loopback-cdp-relay-with-extension-id-pinning-4nne</link>
      <guid>https://dev.to/raknaos/securing-a-loopback-cdp-relay-with-extension-id-pinning-4nne</guid>
      <description>&lt;p&gt;My local relay (127.0.0.1:8765) proxies Chrome DevTools Protocol commands to a headless browser whose cookie jar holds my logged-in sessions. A shared secret in a header was never enough: any web page I visit can fire &lt;code&gt;fetch&lt;/code&gt; at loopback. Same-origin policy stops the &lt;em&gt;response&lt;/em&gt; from being read, but the &lt;em&gt;request&lt;/em&gt; still reaches the server, and a preflight is a request too. So the relay had to answer one question on every call: which extension is actually talking to me?&lt;/p&gt;

&lt;p&gt;Last week &lt;a href="https://github.com/suleyman416" rel="noopener noreferrer"&gt;suleyman416&lt;/a&gt; opened &lt;a href="https://github.com/Raknaos/lightpanda-session-bridge/pull/1" rel="noopener noreferrer"&gt;PR #1&lt;/a&gt; on the Lightpanda Session Bridge with exactly that hardening. I merged it on the 9th after running the tests locally. Here is what landed and the one subtlety that made it interesting.&lt;/p&gt;

&lt;h2&gt;
  
  
  The mechanism
&lt;/h2&gt;

&lt;p&gt;The caller's &lt;code&gt;Origin&lt;/code&gt; header looks like &lt;code&gt;chrome-extension://&amp;lt;id&amp;gt;/...&lt;/code&gt;. The ID is 32 characters from the alphabet &lt;code&gt;a-p&lt;/code&gt; (it is derived from the extension's public key). Validation is a regex fullmatch, not a prefix check:&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="n"&gt;EXTENSION_ID_RE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;^[a-p]{32}$&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;OFFICIAL_EXTENSION_ID&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fcigkjkchglchhohedljlenopbkgnino&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The relay keeps a &lt;em&gt;pinned&lt;/em&gt; ID, checked on every token-delivering or CDP-executing path (&lt;code&gt;/v1/bootstrap&lt;/code&gt;, &lt;code&gt;/v1/sessions&lt;/code&gt;, &lt;code&gt;/v1/cdp&lt;/code&gt;) plus CORS preflights. Default policy: pin the official published ID. If your origin does not match, you get a 403 and the endpoint never runs.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trap: unpacked extensions have path-derived IDs
&lt;/h2&gt;

&lt;p&gt;During development you load the extension unpacked (&lt;code&gt;chrome://extensions&lt;/code&gt; -&amp;gt; Load unpacked), and Chrome derives the extension ID from a hash of the &lt;em&gt;installation path&lt;/em&gt;, not a public key. Every checkout on every machine gets a different ID. A strict default pin would have broken the exact people the project needs: anyone running a custom build.&lt;/p&gt;

&lt;p&gt;The PR solved it with three explicit modes instead of one clever guess:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Default&lt;/strong&gt;: official ID pinned. Safest for users installing the packaged extension.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TOFU&lt;/strong&gt; (&lt;code&gt;LP_BRIDGE_TOFU=1&lt;/code&gt;): first caller pins its ID to &lt;code&gt;~/.config/lightpanda-bridge/pinned_extension_id&lt;/code&gt; (written &lt;code&gt;0600&lt;/code&gt;), everyone else is then checked against it. Trust on first use, like SSH known_hosts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Allowlist&lt;/strong&gt; (&lt;code&gt;LP_BRIDGE_ALLOWED_EXTENSION_IDS&lt;/code&gt;): comma-separated IDs for CI or multi-machine setups.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What TOFU costs you
&lt;/h2&gt;

&lt;p&gt;Trust on first use trusts the &lt;em&gt;first&lt;/em&gt; caller. If anything can race your extension to the relay on a cold start, it pins the attacker's ID and you never notice. That is why TOFU is opt-in and off by default: the failure mode is quiet, and quiet failure modes should require an environment variable, not a code path.&lt;/p&gt;

&lt;p&gt;The practical lesson I take from this PR: when you secure a loopback service, "who is allowed to call me" cannot be answered by the absence of an Origin (that is how curl looks) nor by a token alone (tokens leak into logs and shell history). Bind the secret &lt;em&gt;and&lt;/em&gt; the identity together, make the strict mode the default, and make the permissive mode loud.&lt;/p&gt;

&lt;p&gt;The relay is ~1,200 lines of Python and the whole pinning layer is under 100. If you run an agent with a CDP endpoint on localhost, this is a cheap afternoon of work.", &lt;/p&gt;

</description>
      <category>security</category>
      <category>devtools</category>
      <category>headlessbrowsers</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Your headless browser has as many cookie jars as it has CDP connections</title>
      <dc:creator>Raknaos</dc:creator>
      <pubDate>Wed, 09 Sep 2026 22:47:52 +0000</pubDate>
      <link>https://dev.to/raknaos/your-headless-browser-has-as-many-cookie-jars-as-it-has-cdp-connections-2amj</link>
      <guid>https://dev.to/raknaos/your-headless-browser-has-as-many-cookie-jars-as-it-has-cdp-connections-2amj</guid>
      <description>&lt;h1&gt;
  
  
  Your headless browser has as many cookie jars as it has CDP connections
&lt;/h1&gt;

&lt;p&gt;I learned this the expensive way while running an authenticated headless agent in production.&lt;/p&gt;

&lt;p&gt;The setup was simple on paper: a Chrome extension exports the cookies of a site you are logged into on your desktop, a local relay injects them into &lt;a href="https://lightpanda.io" rel="noopener noreferrer"&gt;Lightpanda&lt;/a&gt;, and an agent drives that Lightpanda instance through the Chrome DevTools Protocol. Real logins, no password storage, no OTP babysitting.&lt;/p&gt;

&lt;p&gt;It worked. Then it randomly stopped working on dev.to specifically.&lt;/p&gt;

&lt;p&gt;The extension synced. The relay reported the cookies as injected. &lt;code&gt;Network.getCookies&lt;/code&gt; on the socket I was using returned the expected jar. And yet every navigation came back as logged out. No error, no redirect to a login page worth debugging — just an anonymous session staring back at me.&lt;/p&gt;

&lt;h2&gt;
  
  
  The jar is scoped to the connection, not to the browser
&lt;/h2&gt;

&lt;p&gt;Here is the part the docs did not tell me: &lt;strong&gt;Lightpanda scopes its cookie jar per CDP connection, not per browser instance.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Which means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Connection A (the relay) imports the session.&lt;/li&gt;
&lt;li&gt;Connection A's &lt;code&gt;getCookies&lt;/code&gt; confirms the cookies are there. True.&lt;/li&gt;
&lt;li&gt;Connection B (my agent, a fresh WebSocket to the same &lt;code&gt;:9222&lt;/code&gt;) navigates.&lt;/li&gt;
&lt;li&gt;Connection B sees an &lt;strong&gt;empty jar&lt;/strong&gt;. Also true.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both statements are correct simultaneously. There is no corruption, no expiry problem, no &lt;code&gt;SameSite&lt;/code&gt; mystery. The two sockets were looking at two different jars that happened to live in the same process. I spent a while suspecting cookie attributes before noticing that the verification call and the navigation call were made on different connections — I was confirming the state of a jar I was never reading from.&lt;/p&gt;

&lt;p&gt;In a normal Chrome this is invisible, because there is effectively one browser-wide jar and CDP is a debugging surface on top of it. In an agent-oriented headless browser, the CDP connection is the primary control plane, and connection-scoped state becomes a first-class architectural property, not a detail.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: one connection to rule them all
&lt;/h2&gt;

&lt;p&gt;The version that ships today has the relay &lt;strong&gt;own the only CDP connection&lt;/strong&gt;, permanently. Agents never open a socket to the browser. They POST their CDP commands to the relay, which replays them on the connection that holds the sessions:&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="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;bridge_agent&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;AuthenticatedSession&lt;/span&gt;

&lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;AuthenticatedSession&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;          &lt;span class="c1"&gt;# talks to the relay, not to :9222
&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://dev.to&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;wait&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;js&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;document.body.getAttribute(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;data-user-status&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# 'logged-in'
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three consequences worth calling out:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Verification has to happen on the acting connection.&lt;/strong&gt; &lt;code&gt;getCookies&lt;/code&gt; from a side socket tells you nothing about what your navigation will see. If you are debugging an auth problem in a headless setup, the very first question is: &lt;em&gt;same connection?&lt;/em&gt; Not same browser, not same process. Same connection.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Restart semantics become explicit.&lt;/strong&gt; When the browser restarts, the jar is gone because the connection is gone. There is no browser state to fall back to. We now replay the last imported session automatically on reconnect and keep on-disk snapshots of each origin's cookie list so a relay restart is a re-import, not a manual re-sync from the desktop.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Security posture changes for the better.&lt;/strong&gt; If agents can open their own CDP socket, they can create a new jar at will, and "who is allowed to read the session" becomes unenforceable. Serialising every command through one relay with a token and strict origin checks (&lt;code&gt;chrome-extension://&lt;/code&gt; only on the endpoints that deliver the secret) turns an implicit trust boundary into an explicit one. The anti-replay win was a side effect of fixing the jar bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd check first, if this is your bug
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Print &lt;code&gt;location.href&lt;/code&gt; after every navigation from the &lt;em&gt;same&lt;/em&gt; connection you navigated on. A target that silently fell back to &lt;code&gt;about:blank&lt;/code&gt; looks exactly like a cookie problem — the page you render isn't the page you think you're authenticating.&lt;/li&gt;
&lt;li&gt;Never verify state on a different socket than the one you act on.&lt;/li&gt;
&lt;li&gt;If your browser exposes &lt;code&gt;/json/list&lt;/code&gt;, check how many live targets and connections you actually have. Two WebSockets attached is the smoking gun.&lt;/li&gt;
&lt;li&gt;Before blaming &lt;code&gt;SameSite&lt;/code&gt;, &lt;code&gt;Secure&lt;/code&gt;, &lt;code&gt;Domain&lt;/code&gt;, or expiry, prove the reading and writing side share a jar. Attribute problems are real, but they are down the list from "you are literally in another browser context".&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Take it with a grain of salt
&lt;/h2&gt;

&lt;p&gt;This is one browser's implementation, not a CDP specification guarantee. What generalises is the lesson: &lt;strong&gt;when a headless browser is designed for automation, the CDP connection is a security and state boundary, and you should assume it holds state until proven otherwise.&lt;/strong&gt; If your stack was built assuming one browser equals one session store, that assumption will fail quietly, and it will fail in the direction that looks like an authentication bug.&lt;/p&gt;

&lt;p&gt;If you've hit an equivalent in Playwright storage states, Puppeteer's incognito contexts, or another headless engine, I'd genuinely like to know how you handled the "which connection am I actually reading?" problem. That question cost me a day and it should not have.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The bridge this came out of is open source: &lt;a href="https://github.com/Raknaos/lightpanda-session-bridge" rel="noopener noreferrer"&gt;https://github.com/Raknaos/lightpanda-session-bridge&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>automation</category>
      <category>debugging</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Handing Real Logins to Headless AI Agents: Building the Lightpanda Session Bridge</title>
      <dc:creator>Raknaos</dc:creator>
      <pubDate>Mon, 07 Sep 2026 22:00:10 +0000</pubDate>
      <link>https://dev.to/raknaos/handing-real-logins-to-headless-ai-agents-building-the-lightpanda-session-bridge-17je</link>
      <guid>https://dev.to/raknaos/handing-real-logins-to-headless-ai-agents-building-the-lightpanda-session-bridge-17je</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; When autonomous AI agents need to interact with modern web dashboards, handing them passwords or session tokens in prompts is a security disaster. I built &lt;a href="https://github.com/Raknaos/lightpanda-session-bridge" rel="noopener noreferrer"&gt;&lt;strong&gt;Lightpanda Session Bridge&lt;/strong&gt;&lt;/a&gt; — an open-source MV3 Chrome extension and a hardened loopback relay that safely replicates your live browser session into a local &lt;a href="https://lightpanda.io" rel="noopener noreferrer"&gt;Lightpanda&lt;/a&gt; headless runtime via CDP. Zero credentials typed, zero secrets exposed to LLMs.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;If you build AI agents that do real work on the modern web, you know the exact wall every developer hits: &lt;strong&gt;authentication&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The moment your agent needs to check an AWS billing console, inspect private logs on a SaaS dashboard, or pull data from an internal portal, the demo breaks down. Modern apps don’t live on basic auth; they sit behind Google OAuth, SSO federations, hardware passkeys, and biometric 2FA prompts. &lt;/p&gt;

&lt;p&gt;A headless browser cannot tap your security key, answer your phone's authenticator app, or blink at a FaceID prompt. &lt;/p&gt;

&lt;p&gt;Faced with this, most builders resort to terrible compromises:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Hardcoding passwords into agent prompts or &lt;code&gt;.env&lt;/code&gt; files&lt;/strong&gt; (which leak into LLM context logs, chat histories, and traces).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Copy-pasting session cookies manually into configs&lt;/strong&gt; (which expire quickly and offer zero scoping or SSRF protection).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Driving the user’s primary browser via raw CDP&lt;/strong&gt; (which disrupts real work, risks hijacking other tabs, and introduces scary blast radiuses).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The &lt;strong&gt;Lightpanda Session Bridge&lt;/strong&gt; is built on a different philosophy: &lt;strong&gt;keep the authentication ritual with the human, and hand the agent an isolated, authenticated runtime.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;+-----------------------------------------------------------------------+
|  HUMAN BROWSER (Chrome / Edge / Comet)                                |
|  User logs in via Passkey / Google OAuth / 2FA                        |
|                                                                       |
|  [ 🐼 Sync Tab ] ---&amp;gt; Extension MV3 extracts strictly scoped cookies   |
+---------------------------------------+-------------------------------+
                                        | POST 127.0.0.1:8765
                                        | (with X-Bridge-Token + CORS check)
                                        v
+-----------------------------------------------------------------------+
|  LOCAL BRIDGE RELAY (relay/server.py)                                 |
|  - Loopback-only (127.0.0.1)                                          |
|  - IdP &amp;amp; Private IP blocking (anti-SSRF + DNS cache)                  |
|  - Cookie normalization (__Host-, __Secure-, RFC 6265bis)             |
+---------------------------------------+-------------------------------+
                                        | WebSocket CDP Protocol
                                        v
+-----------------------------------------------------------------------+
|  HEADLESS RUNTIME (Lightpanda in WSL2 @ :9222)                        |
|  - Isolated V8 / Zig engine                                           |
|  - Instant DOM / JS evaluation                                        |
|                                                                       |
|  AI Agent reads data via SDK (lightpanda_client.py)                   |
+-----------------------------------------------------------------------+
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Why Session Transfer Beats Credential Sharing
&lt;/h2&gt;

&lt;p&gt;Passwords and API tokens are the wrong unit of trust for agents. They grant permanent, unrestricted access. Once an LLM agent has your password, you have zero guarantee where that string will travel — subagent handoffs, external telemetry, debug dumps, or third-party inference providers.&lt;/p&gt;

&lt;p&gt;A &lt;strong&gt;session cookie&lt;/strong&gt; is fundamentally safer:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It is &lt;strong&gt;ephemeral&lt;/strong&gt; and expires automatically.&lt;/li&gt;
&lt;li&gt;It can be &lt;strong&gt;instantly revoked&lt;/strong&gt; from your main browser simply by logging out.&lt;/li&gt;
&lt;li&gt;It is &lt;strong&gt;strictly scoped&lt;/strong&gt; to a single target origin.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;With the bridge, you authenticate once in your familiar browser. When you click &lt;strong&gt;Sync&lt;/strong&gt;, the extension packages only the cookies relevant to that specific origin and pushes them into an isolated headless browser instance. &lt;/p&gt;

&lt;p&gt;The LLM never sees your credentials. The relay never logs a cookie value. The machine gets straight to work.&lt;/p&gt;




&lt;h2&gt;
  
  
  Architecture: The Three Layers
&lt;/h2&gt;

&lt;p&gt;The architecture is purposely minimal, robust, and audit-friendly:&lt;/p&gt;

&lt;h3&gt;
  
  
  1. The Chrome Extension (Manifest V3)
&lt;/h3&gt;

&lt;p&gt;Designed with a clean, dark Quota Glass interface. It requires only standard scoped permissions (&lt;code&gt;activeTab&lt;/code&gt;, &lt;code&gt;cookies&lt;/code&gt;, &lt;code&gt;storage&lt;/code&gt;). When clicked, it captures cookies for the active domain, normalizes them, and prepares a transfer envelope.&lt;/p&gt;

&lt;p&gt;On first launch, it executes an &lt;strong&gt;auto-pairing handshake&lt;/strong&gt; (&lt;code&gt;/v1/bootstrap&lt;/code&gt;) with the local relay, storing a shared cryptographic token in local isolated storage without requiring manual copy-pasting.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. The Hardened Loopback Relay (&lt;code&gt;relay/server.py&lt;/code&gt;)
&lt;/h3&gt;

&lt;p&gt;Listening exclusively on &lt;code&gt;127.0.0.1:8765&lt;/code&gt;, the relay is the security gateway. It:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Enforces strict origin matching.&lt;/li&gt;
&lt;li&gt;Translates browser cookie structures into Lightpanda-compliant DevTools protocol messages (including converting lowercase &lt;code&gt;sameSite&lt;/code&gt; tags like &lt;code&gt;lax&lt;/code&gt; to Lightpanda's PascalCase &lt;code&gt;Lax&lt;/code&gt; to avoid &lt;code&gt;-31998 InvalidEnumTag&lt;/code&gt; CDP crashes).&lt;/li&gt;
&lt;li&gt;Normalizes &lt;code&gt;__Host-&lt;/code&gt; and &lt;code&gt;__Secure-&lt;/code&gt; cookie prefixes per RFC 6265bis.&lt;/li&gt;
&lt;li&gt;Forwards cookies over CDP WebSockets to the headless engine.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  3. Lightpanda Headless Engine
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://lightpanda.io" rel="noopener noreferrer"&gt;Lightpanda&lt;/a&gt; is an ultra-fast, open-source headless browser built in Zig with V8, purpose-built for AI automation. Running Lightpanda in WSL2 isolates it from your Windows host environment while keeping execution blindingly fast with tiny memory footprints compared to full Chromium.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Security Checklist: Defending Against SSRF &amp;amp; Local Leaks
&lt;/h2&gt;

&lt;p&gt;Treating a local HTTP relay as a trusted boundary is how local privilege escalation happens. Because the relay accepts cookies, I designed it as an adversarial SSRF surface from day one:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;🛡️ &lt;strong&gt;Zero Logging:&lt;/strong&gt; Cookie names and values are never printed to stdout, logged to disk, or saved in history.&lt;/li&gt;
&lt;li&gt;🔒 &lt;strong&gt;Loopback Only:&lt;/strong&gt; Hardcoded binding to &lt;code&gt;127.0.0.1&lt;/code&gt;. No routable network interfaces exposed.&lt;/li&gt;
&lt;li&gt;🚫 &lt;strong&gt;Strict Identity-Provider (IdP) Blacklisting:&lt;/strong&gt; The relay automatically rejects transfers intended for identity roots — &lt;code&gt;accounts.google.com&lt;/code&gt;, &lt;code&gt;login.microsoftonline.com&lt;/code&gt;, &lt;code&gt;appleid.apple.com&lt;/code&gt;, &lt;code&gt;github.com&lt;/code&gt;, and &lt;code&gt;auth0.com&lt;/code&gt; cannot be targeted.&lt;/li&gt;
&lt;li&gt;🛑 &lt;strong&gt;SSRF IP &amp;amp; DNS Verification:&lt;/strong&gt; Target domains must resolve to valid public IPv4/IPv6 addresses. Localhost aliases, &lt;code&gt;127.0.0.0/8&lt;/code&gt;, private subnets (&lt;code&gt;10.0.0.0/8&lt;/code&gt;, &lt;code&gt;192.168.0.0/16&lt;/code&gt;), and wildcard DNS tools like &lt;code&gt;nip.io&lt;/code&gt; are categorically dropped. DNS lookups are pinned with a 60-second cache to prevent time-of-check to time-of-use (TOCTOU) rebinding.&lt;/li&gt;
&lt;li&gt;🔑 &lt;strong&gt;Origin-Restricted Handshake:&lt;/strong&gt; Web pages or rogue local CLI scripts attempting to query &lt;code&gt;/v1/bootstrap&lt;/code&gt; receive an immediate &lt;code&gt;403 Forbidden&lt;/code&gt;. Only callers presenting a legitimate &lt;code&gt;chrome-extension://&lt;/code&gt; Origin header can receive the pairing secret.&lt;/li&gt;
&lt;li&gt;🧪 &lt;strong&gt;Live Verified:&lt;/strong&gt; Backed by 9 automated security test suites, validating private IP rejections, CDP payload structures, and token enforcement.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  How AI Agents Interact With The Session
&lt;/h2&gt;

&lt;p&gt;Once the session is synced into Lightpanda, your agent script uses the bundled lightweight Python SDK (&lt;code&gt;lightpanda_client.py&lt;/code&gt;):&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="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;lightpanda_client&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;LightpandaClient&lt;/span&gt;

&lt;span class="c1"&gt;# 1. Connect to Lightpanda CDP runtime
&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;LightpandaClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cdp_ws&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ws://127.0.0.1:9222/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="c1"&gt;# 2. Attach to or spawn the target page (already carrying the synced session)
&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;attach_or_create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://app.example.com/dashboard&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# 3. Evaluate JavaScript inside the authenticated session context
&lt;/span&gt;&lt;span class="n"&gt;dashboard_data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;evaluate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;(() =&amp;gt; {
    return {
        user: document.querySelector(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;.user-profile&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;)?.textContent?.trim(),
        quotaRemaining: document.querySelector(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;.quota-display&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;)?.textContent?.trim(),
        csrfToken: document.querySelector(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;meta[name=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;csrf-token&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;]&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;)?.content
    };
})()&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Agent operating as: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;dashboard_data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;user&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Remaining quota: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;dashboard_data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;quotaRemaining&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The agent never asked for a password. The user never risked account takeover.&lt;/p&gt;




&lt;h2&gt;
  
  
  Quickstart (Under 3 Minutes)
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Clone &amp;amp; Install Dependencies
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/Raknaos/lightpanda-session-bridge.git
&lt;span class="nb"&gt;cd &lt;/span&gt;lightpanda-session-bridge
pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; requirements.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  2. Launch Lightpanda &amp;amp; The Bridge Relay
&lt;/h3&gt;

&lt;p&gt;In two PowerShell terminals:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;/scripts/start-lightpanda.ps1&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="c"&gt;# Runs Lightpanda CDP on 127.0.0.1:9222 (WSL2)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;/scripts/start-relay.ps1&lt;/span&gt;&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="c"&gt;# Starts relay on 127.0.0.1:8765&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  3. Load the Extension
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;Open &lt;code&gt;chrome://extensions&lt;/code&gt; in Chrome, Comet, or Edge.&lt;/li&gt;
&lt;li&gt;Toggle &lt;strong&gt;Developer Mode&lt;/strong&gt; on.&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;Load unpacked&lt;/strong&gt; and select the repository's &lt;code&gt;extension/&lt;/code&gt; folder.&lt;/li&gt;
&lt;li&gt;Open the popup once while the relay runs — it auto-pairs instantly.&lt;/li&gt;
&lt;li&gt;Navigate to any authenticated site, click the 🐼 icon, and hit &lt;strong&gt;Sync Session&lt;/strong&gt;.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Honest Limitations
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Human-in-the-loop:&lt;/strong&gt; You must click &lt;strong&gt;Sync&lt;/strong&gt; once per session. This is an intentional security design choice, but it means this is built for supervised agent workflows, not headless server farms starting from scratch.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Local machine only:&lt;/strong&gt; The relay strictly refuses remote connections. Your agent script and your browser must reside on the same workstation or dev environment.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Zig / WSL2 dependency:&lt;/strong&gt; Lightpanda currently runs most smoothly on Linux/WSL2; the PowerShell scripts manage this automatically for Windows setups.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Try It Out &amp;amp; Contribute
&lt;/h2&gt;

&lt;p&gt;The project is fully open-source under the MIT license:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;📦 &lt;strong&gt;GitHub Repository:&lt;/strong&gt; &lt;a href="https://github.com/Raknaos/lightpanda-session-bridge" rel="noopener noreferrer"&gt;Raknaos/lightpanda-session-bridge&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;🌐 &lt;strong&gt;Project Landing Page:&lt;/strong&gt; &lt;a href="https://raknaos.github.io/lightpanda-session-bridge/" rel="noopener noreferrer"&gt;raknaos.github.io/lightpanda-session-bridge&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;🏷️ &lt;strong&gt;Release v0.3.4 (Zip Packaged):&lt;/strong&gt; &lt;a href="https://github.com/Raknaos/lightpanda-session-bridge/releases/tag/v0.3.4" rel="noopener noreferrer"&gt;GitHub Releases&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're building autonomous agents that need to navigate authenticated environments safely, take it for a spin and star the repo! Feedback, issues, and PRs are warmly welcome.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>security</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
