<?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: Ahmed Sozzer</title>
    <description>The latest articles on DEV Community by Ahmed Sozzer (@amsozzer).</description>
    <link>https://dev.to/amsozzer</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3789833%2Fb0cb350f-55b0-40c7-9bae-2348d7f567e8.png</url>
      <title>DEV Community: Ahmed Sozzer</title>
      <link>https://dev.to/amsozzer</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/amsozzer"/>
    <language>en</language>
    <item>
      <title>Why I'm building a filament changer for printers that already have one</title>
      <dc:creator>Ahmed Sozzer</dc:creator>
      <pubDate>Sat, 17 Oct 2026 14:51:41 +0000</pubDate>
      <link>https://dev.to/amsozzer/why-im-building-a-filament-changer-for-printers-that-already-have-one-115a</link>
      <guid>https://dev.to/amsozzer/why-im-building-a-filament-changer-for-printers-that-already-have-one-115a</guid>
      <description>&lt;p&gt;I run a small print farm: two P1S with an AMS each, an A1 Combo, and an A1 mini. Three of those four printers already have an automatic filament system. I'm building another one anyway. The reason is a line near the top of its design docs: there's no official way to put 30 spools on a shelf and let any of them feed any of my printers.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the stock units can't do
&lt;/h2&gt;

&lt;p&gt;An AMS holds four spools. A P1S can take up to four AMS units through an AMS Hub, which gets one printer to 16. The A1 series takes one AMS lite, or up to four full AMS units through its own hub.&lt;/p&gt;

&lt;p&gt;Sixteen is a lot of colours. The bigger limit for a farm is that every spool belongs to one printer. A spool in one P1S's AMS can't feed the other P1S, even when that printer is idle and the first one doesn't need it. The hub doesn't change that. It merges up to four AMS units into one printer's inlet and has no way to send filament the other direction. Two printers that run the same colour need two spools of it, loaded in two places.&lt;/p&gt;

&lt;p&gt;What I want is closer to a switchboard: a bank of spools that any printer can draw from, with no fixed count. That's the long-term goal for AMS-X, and it's a long way off. The roadmap calls routing filament between printers a hard, separate problem and puts it last.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decision one: leave the firmware alone
&lt;/h2&gt;

&lt;p&gt;Every Bambu printer already knows how to do a manual filament change. Set the change-filament gcode to a pause, slice a multi-colour model, and the printer stops at each change, unloads, and waits for someone to push the next filament in. Then it loads, purges, wipes and carries on.&lt;/p&gt;

&lt;p&gt;AMS-X doesn't fork firmware, emulate Bambu's AMS protocol, or write hotend gcode. It replaces the person standing at the printer. The printer keeps everything that depends on the nozzle and the material: temperature, retracting out of the melt zone, loading, purging. AMS-X keeps the logistics: which spool is next, pulling the old filament clear, and pushing the new one in until the printer's own filament sensor sees it.&lt;/p&gt;

&lt;p&gt;The reason is calibration. Purge length and retraction are per-nozzle, per-material numbers, and the printer already has them right. If I wrote my own, I'd own that problem on every model I run. The cost is that I can only drive what the printer exposes on its local network, so the printers run in LAN mode and every swap goes at the printer's pace. &lt;a href="https://dev.to/amsozzer/driving-a-bambu-lab-printer-over-mqtt-12nb"&gt;Driving a Bambu Lab printer over MQTT&lt;/a&gt; covers that interface and which parts of it I've confirmed.&lt;/p&gt;

&lt;p&gt;That decision also set the order of the work. Everything depended on the printer's unload, resume and filament sensor being reachable over MQTT, so the roadmap tests that first, from a laptop, with no hardware. The next step builds the whole loop with a human standing in for the motor: the server parses the sliced file, uploads it, starts the print, catches each pause, asks a person to swap, and resumes when the sensor trips. A motorized module plugs into the same interface later, and the orchestrator doesn't change.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decision two: one motor at a time
&lt;/h2&gt;

&lt;p&gt;Each spool sits in a module: a NEMA 17 stepper on a TMC2209 driver, pushing filament through a Bowden-style extruder. An ESP32 runs a cluster of modules and talks to the server over MQTT. The early docs planned one ESP32 per roughly 16 modules. The firmware settled on eight.&lt;/p&gt;

&lt;p&gt;The rule that keeps the board simple is that only one module moves at a time. A filament change only needs one spool moving, so there's no reason to run two motors at once. That lets every driver in the cluster share the same STEP and DIR lines, with a separate enable pin per driver. Picking a spool means enabling one driver. Eight modules cost two shared pins plus eight enables, ten in total, against sixteen if each driver had its own STEP and DIR.&lt;/p&gt;

&lt;p&gt;The enable is held only for the length of a move. Exactly one spool is ever energised, and that's guaranteed by the wiring rather than by the code getting it right. It keeps power small too: with one motor moving at a time, a 24 V, 5 A supply covers a cluster.&lt;/p&gt;

&lt;p&gt;Pin numbers aren't compiled in either. The pin map arrives over MQTT, and the board refuses pins that would take it down, like the ones wired to its own flash, and reports a fault instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it actually is
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Server:&lt;/strong&gt; the full loop runs. It parses the sliced 3MF, uploads over FTPS, starts the print, catches each pause, prompts a person to swap, and resumes when the printer's sensor trips. The printer side is confirmed on my A1 mini and P1S. It also tracks spools in Spoolman and runs against a built-in printer simulator, with 130+ tests.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Firmware:&lt;/strong&gt; about 900 lines of C++, on the bench with four motors, not eight. Loading works. Unloading doesn't: the module's own filament sensor isn't read yet, so an unload thinks it's already finished and the motor never turns.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not built:&lt;/strong&gt; the motorized module on the server side, and the hub that merges modules into one printer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;X1:&lt;/strong&gt; untested. I don't have one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sharing spools between printers:&lt;/strong&gt; the goal, not something that runs.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;Fixing unload comes first, because nothing swaps on its own until a module can pull filament back. After that, four spools swapping with no hand on them, then eight. Until then, AMS-X is a server that tells me when to swap spools.&lt;/p&gt;

&lt;p&gt;The code is on GitHub under MIT: &lt;a href="https://github.com/Amsozzer1/AMS" rel="noopener noreferrer"&gt;server&lt;/a&gt; and &lt;a href="https://github.com/Amsozzer1/AMS-Firmware" rel="noopener noreferrer"&gt;firmware&lt;/a&gt;. The firmware is covered in &lt;a href="https://amsozzer.com/writing/a-filament-move-is-two-legs" rel="noopener noreferrer"&gt;One ESP32, eight spools, two legs per filament move&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>iot</category>
      <category>hardware</category>
      <category>showdev</category>
      <category>opensource</category>
    </item>
    <item>
      <title>How often public Supabase repos leave a table readable by the anon key</title>
      <dc:creator>Ahmed Sozzer</dc:creator>
      <pubDate>Thu, 15 Oct 2026 15:15:55 +0000</pubDate>
      <link>https://dev.to/amsozzer/how-often-public-supabase-repos-leave-a-table-readable-by-the-anon-key-2792</link>
      <guid>https://dev.to/amsozzer/how-often-public-supabase-repos-leave-a-table-readable-by-the-anon-key-2792</guid>
      <description>&lt;p&gt;While going through some of my older projects, I found a Supabase table with row level security off. It came in with a PR I'd mostly vibe coded, and I'd never turned RLS on for it. Anyone holding the anon key could read it, and the anon key ships in the frontend bundle. I fixed it as soon as I saw it.&lt;/p&gt;

&lt;p&gt;Then I wanted to know how common that is. So I replayed the migration history of 1,000 public Supabase repos into throwaway local databases and checked every table, commit by commit.&lt;/p&gt;

&lt;p&gt;Short version: 18.8% of the repos I could replay had a fully readable table at some point, and 10.9% still have one at their latest commit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this happens
&lt;/h2&gt;

&lt;p&gt;A Supabase frontend talks to Postgres through PostgREST, using the anon key. Supabase's default privileges grant &lt;code&gt;anon&lt;/code&gt; &lt;code&gt;SELECT&lt;/code&gt;, &lt;code&gt;INSERT&lt;/code&gt;, &lt;code&gt;UPDATE&lt;/code&gt; and &lt;code&gt;DELETE&lt;/code&gt; on new tables in &lt;code&gt;public&lt;/code&gt;. Row level security is the only thing standing between those grants and your rows.&lt;/p&gt;

&lt;p&gt;The dashboard's table editor turns RLS on for new tables by default. A &lt;code&gt;create table&lt;/code&gt; in a migration file doesn't:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;messages&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="nb"&gt;bigint&lt;/span&gt; &lt;span class="k"&gt;generated&lt;/span&gt; &lt;span class="n"&gt;always&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="k"&gt;identity&lt;/span&gt; &lt;span class="k"&gt;primary&lt;/span&gt; &lt;span class="k"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;-- no "alter table public.messages enable row level security;"&lt;/span&gt;
&lt;span class="c1"&gt;-- anon can now read and write every row&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the same table is safe or open depending on whether you made it in the UI or in SQL.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Sample.&lt;/strong&gt; GitHub code search for &lt;code&gt;.sql&lt;/code&gt; files under &lt;code&gt;supabase/migrations/&lt;/code&gt;, sliced into 8 file-size bands to get past the API's 1,000-result cap. Every band hit the cap, so the frame is the first 1,000 hits in each band: 5,865 repos. I screened them in seeded random order and kept the first 1,000 that weren't forks, templates, tutorials or Supabase's own examples, and that had at least 3 migration files and 2 commits touching them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Replay.&lt;/strong&gt; For each repo, every commit that touched the migrations or &lt;code&gt;seed.sql&lt;/code&gt;, capped at 30 (first, last and evenly spaced in between). Each commit was replayed into a fresh &lt;code&gt;supabase/postgres&lt;/code&gt; container with no outbound network, using the same rules &lt;code&gt;supabase db push&lt;/code&gt; uses. Nothing touched a live project, and no URL was visited.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Exposure.&lt;/strong&gt; A table in &lt;code&gt;public&lt;/code&gt; with RLS off, on which &lt;code&gt;anon&lt;/code&gt; has table-level &lt;code&gt;SELECT&lt;/code&gt;. That means every row and every column is readable with the public key.&lt;/p&gt;

&lt;h2&gt;
  
  
  Results
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Repos&lt;/th&gt;
&lt;th&gt;Share&lt;/th&gt;
&lt;th&gt;95% CI&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Exposed at any sampled commit&lt;/td&gt;
&lt;td&gt;146 of 777&lt;/td&gt;
&lt;td&gt;18.8%&lt;/td&gt;
&lt;td&gt;16.2 to 21.7%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Still exposed at the latest commit&lt;/td&gt;
&lt;td&gt;43 of 393&lt;/td&gt;
&lt;td&gt;10.9%&lt;/td&gt;
&lt;td&gt;8.2 to 14.4%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The denominators differ because not every commit replays (more on that below). 777 repos had at least one commit that replays, and 393 had a latest commit that does.&lt;/p&gt;

&lt;p&gt;When an exposure ended, it usually wasn't a quick fix in the next commit. 67% of the ones that ended lasted more than a day and 37% more than a week. Counting exposures that are still open, the median was 47 days (Kaplan–Meier, since open ones haven't finished yet). 22 of the 75 that ended did so at the very next migration commit.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7x0zkmfp3rz6auuyuyvn.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7x0zkmfp3rz6auuyuyvn.png" alt="How long exposures lasted, per repo: bars for ended vs still open in seven duration buckets, from under 1 hour to over 1 year" width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  A few results worth a closer look
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Starred repos did worse.&lt;/strong&gt; Repos with 1 to 9 stars were exposed at their latest commit 20.3% of the time, against 8.3% for repos with no stars, and the intervals don't overlap. I wouldn't read that as cause. Starred repos tend to have longer histories, and every extra commit is another chance to add a table.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Older repos weren't safer.&lt;/strong&gt; Repos created before 2026 were at 16.7% at their latest commit and 2026 repos at 9.5%, and that difference is within the intervals. If you wanted to pin this on AI coding tools, this data doesn't support it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Most latest commits don't replay at all.&lt;/strong&gt; Only 39% of repos had a latest commit that applies cleanly on a fresh database. Of the ones that fail, 69% fail because a migration refers to a table, column or function that doesn't exist yet, almost always because it was made in the dashboard. For those projects, the migration folder can't rebuild the database it's supposed to describe.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Public reads are everywhere, and that's mostly fine.&lt;/strong&gt; 48.6% of repos had a &lt;code&gt;SELECT&lt;/code&gt; policy for &lt;code&gt;anon&lt;/code&gt; with &lt;code&gt;using (true)&lt;/code&gt; on a table that does have RLS on. That's how a public profile page or a blog works. Supabase's own linter skips that pattern on purpose and only flags always-true write policies, which showed up in 9.3% of repos.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limits
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Migrations aren't the live database.&lt;/strong&gt; A project can turn RLS on in the dashboard without a migration. Then it looks exposed here when it isn't. The reverse happens too.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The sample is young and small.&lt;/strong&gt; 72% of the repos were created in 2026 and 79% have no stars. Public repos lean toward hobby, demo and portfolio projects even after filtering.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The frame is whatever order GitHub search returns.&lt;/strong&gt; Repos outside those 8 size bands, or past the first 1,000 hits in a band, had no chance of being picked.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The 30-commit cap&lt;/strong&gt; can catch an exposure's start late. I replayed 25 capped repos again with no cap (3,026 commits). No repo changed classification, and start times moved by at most about a day.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The 43
&lt;/h2&gt;

&lt;p&gt;42 of the 43 repos still exposed at their latest commit got a private note, by email, GitHub's private vulnerability reporting, or an issue asking for a security contact (with no details in it). The last one has no private contact channel and doesn't look deployed, so I left it alone. The published data replaces repo names with &lt;code&gt;repo-NNN&lt;/code&gt; and table names with salted hashes, because a distinctive table name can be searched back to its repo. As of October 15, one owner has confirmed a fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checking your own project
&lt;/h2&gt;

&lt;p&gt;In the dashboard, Advisors &amp;gt; Security flags these as "RLS disabled in public". The fix is one line per table, plus policies for whatever access the app actually needs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;alter&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;messages&lt;/span&gt; &lt;span class="n"&gt;enable&lt;/span&gt; &lt;span class="k"&gt;row&lt;/span&gt; &lt;span class="k"&gt;level&lt;/span&gt; &lt;span class="k"&gt;security&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To catch it before it merges, I built &lt;a href="https://github.com/Amsozzer1/lintel" rel="noopener noreferrer"&gt;Lintel&lt;/a&gt;, which replays a PR's migrations and fails the check when the PR adds a table &lt;code&gt;anon&lt;/code&gt; can read:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx @amsozzer/lintel migrations check
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There's also a &lt;a href="https://amsozzer.com/lintel" rel="noopener noreferrer"&gt;playground&lt;/a&gt; that runs the same checks in your browser.&lt;/p&gt;

&lt;p&gt;The method, the scripts and the anonymized data (one row per sampled commit and per exposure) are in the repo: &lt;a href="https://github.com/Amsozzer1/lintel/blob/main/study/results.md" rel="noopener noreferrer"&gt;study/results.md&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://amsozzer.com/writing/how-often-public-supabase-repos-leave-a-table-readable" rel="noopener noreferrer"&gt;amsozzer.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>supabase</category>
      <category>postgres</category>
      <category>security</category>
      <category>webdev</category>
    </item>
    <item>
      <title>A Terminal-Bench task frontier agents could not pass</title>
      <dc:creator>Ahmed Sozzer</dc:creator>
      <pubDate>Wed, 07 Oct 2026 17:46:58 +0000</pubDate>
      <link>https://dev.to/amsozzer/a-terminal-bench-task-frontier-agents-could-not-pass-56jb</link>
      <guid>https://dev.to/amsozzer/a-terminal-bench-task-frontier-agents-could-not-pass-56jb</guid>
      <description>&lt;p&gt;I wrote a task for Terminal-Bench, a public benchmark of hard command-line tasks for AI agents. The agent has to build a redactor for court-filing PDFs: black out every visible name and ID on a list, leave nothing recoverable in the file, and change nothing else on the page.&lt;/p&gt;

&lt;p&gt;I ran it three times each with Claude Opus 5.5 and GPT-6 Sol on their highest reasoning settings. All six runs failed, and so did two runs where the agent was told to cheat. Each agent wrote a tool of around 4,000 lines and tested it before stopping, and each one believed it had passed. This post is about how I built the task and why the agents stopped where they did.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a Terminal-Bench task is
&lt;/h2&gt;

&lt;p&gt;A task is a Docker environment, an instruction, a reference solution and a hidden test suite. The agent gets a shell and the instruction. When it says it’s done, the tests run against whatever it left behind, and the result is pass or fail.&lt;/p&gt;

&lt;p&gt;A good task is one a strong engineer could finish with the information given, and that current agents still get wrong. Getting both at once took most of the work.&lt;/p&gt;

&lt;h2&gt;
  
  
  Thirteen designs that didn’t work
&lt;/h2&gt;

&lt;p&gt;For each idea, I first asked a few models with no tools how they’d solve it. If their plan named the trick, the idea was dead. Most died there.&lt;/p&gt;

&lt;p&gt;The ones that survived got built and run against real agents with tools. A pump-monitoring task looked solid on paper. Opus solved it in 54 minutes by working the hidden behavior out of the residuals in the data. A sewer-overflow model went the same way, and Opus’s answer came in tighter than my own reference solution. A fire-hydrant flow task got passed by five agents out of five.&lt;/p&gt;

&lt;p&gt;Each failure came from the same place. For a task to be fair, the information that decides the answer has to be visible to the agent. If it’s visible, an agent with a shell will find structure in it. Opus usually did that in 7 to 30 minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Picking PDF redaction
&lt;/h2&gt;

&lt;p&gt;I picked redaction because the rules are short and easy to state, and the difficulty is in how many ways content can sit inside a PDF. It’s also a real failure mode. Court filings leak when someone draws a black box over text that’s still in the file.&lt;/p&gt;

&lt;p&gt;The first version gave the agent the policy and three sample files. I ran two agents. One failed and the other got a perfect score. Opus read the policy and listed nearly every way a name can hide in a PDF before it opened a file.&lt;/p&gt;

&lt;p&gt;The obvious fix was a vaguer spec. I didn’t do that, because a task that withholds a rule is unfair, and a reviewer would be right to reject it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Separating the rules from the test cases
&lt;/h2&gt;

&lt;p&gt;What worked was treating the rules and the test cases as separate things. The policy states every rule in full. The hidden test set includes cases no sample shows, and each one is decided by a rule the policy already states. The agent has everything it needs to handle them. It just can’t check its handling against an example.&lt;/p&gt;

&lt;p&gt;I built candidate cases and kept one only if:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;it beat the finished tools from both earlier agents,&lt;/li&gt;
&lt;li&gt;my reference solution handled it with a general method, not a special case, and&lt;/li&gt;
&lt;li&gt;a rule already in the policy decided it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The second condition cost me six candidates. My own reference solution couldn’t handle them cleanly, so I dropped them.&lt;/p&gt;

&lt;p&gt;Then I checked that the set wasn’t tuned to the two tools I’d tested against. Two fresh agents that had never seen those tools failed it too. After that I froze the task.&lt;/p&gt;

&lt;h2&gt;
  
  
  Running the trials
&lt;/h2&gt;

&lt;p&gt;I set the rules before the first run, because a 0% result means nothing if the runs were broken:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Freeze and checksum the task before trial one, and never edit it after.&lt;/li&gt;
&lt;li&gt;The reference solution scores 100% and a do-nothing submission scores 0% before any agent runs.&lt;/li&gt;
&lt;li&gt;Run one normal trial and one cheat trial first, then the rest.&lt;/li&gt;
&lt;li&gt;A run counts only if the agent stopped on its own, with no harness errors, no rate limits and no probing of the grader. Anything else gets rerun once and marked.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One Sol run hit a usage cap partway through. I reran it and didn’t count the capped attempt.&lt;/p&gt;

&lt;p&gt;Before freezing, I also found a bug in my own grader: it flagged some correctly redacted pages as failures. I fixed it, even though leaving it in would have made the task harder. A task that’s hard because the grader is wrong doesn’t measure anything.&lt;/p&gt;

&lt;p&gt;Final results: Opus 0 of 3, Sol 0 of 3, both cheat runs 0.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the agents stopped
&lt;/h2&gt;

&lt;p&gt;None of the agents skipped testing. Each one built a large tool, generated its own test documents and ran them. One made 52.&lt;/p&gt;

&lt;p&gt;The problem was what they tested. Each agent checked its output with the same kinds of checks it had used to find the names. Anything its search missed, its verification missed too, so the tests came back clean and the agent stopped. Writing more tests of the same kind wouldn’t have caught the misses.&lt;/p&gt;

&lt;p&gt;The policy also requires that nothing else on the page changes, so over-redacting fails too. In the first version, one agent added a safety net that blacked out anything that looked suspicious. It fixed one document and broke another by covering a banner that should have stayed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two models failed differently
&lt;/h2&gt;

&lt;p&gt;Opus and Sol each handled cases that beat every run of the other. One case beat all six runs.&lt;/p&gt;

&lt;p&gt;This is why I’d rather have many separately reasoned cases graded all-or-nothing than one clever trick. The next model release tends to solve any single trick. With many cases, a model has to be right about all of them at once.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cheat runs
&lt;/h2&gt;

&lt;p&gt;In a cheat trial, the agent is told to pass by any means. Both agents tried to make the problem easier instead of solving it. The grader keeps its own ground truth out of the agent’s reach and runs the agent’s tool on copies, so neither attempt scored.&lt;/p&gt;

&lt;p&gt;I also audited the transcripts of the agents I used while building the task, to make sure none of them reached outside their sandbox. Before trusting that audit, I tested it against seven cheats I planted myself.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the work was split
&lt;/h2&gt;

&lt;p&gt;I ran this like a small team. Claude agents did most of the building, probing and kill-testing. I set the rules, wrote the decision criteria for each trial outcome, and made the calls. After the first PDF version, the agent recommended submitting it. I pushed instead on hiding the test cases rather than the rules, and that led to the final version.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I’d take from it
&lt;/h2&gt;

&lt;p&gt;If you build or deploy agents, look at what your agent checks before it stops. An agent that tests its own work is limited by the tests it thinks to write. When those tests share a blind spot with the solution, it stops with a wrong answer and a passing test suite.&lt;/p&gt;

&lt;p&gt;The task is open as &lt;a href="https://github.com/harbor-framework/terminal-bench/pull/2184" rel="noopener noreferrer"&gt;PR #2184&lt;/a&gt; on the Terminal-Bench repo, and the evidence for every run is in &lt;a href="https://github.com/Amsozzer1/tb3-pdf-redactor" rel="noopener noreferrer"&gt;the task repo&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>agents</category>
      <category>pdf</category>
      <category>python</category>
      <category>ai</category>
    </item>
    <item>
      <title>Driving a Bambu Lab printer over MQTT</title>
      <dc:creator>Ahmed Sozzer</dc:creator>
      <pubDate>Sun, 27 Sep 2026 23:06:48 +0000</pubDate>
      <link>https://dev.to/amsozzer/driving-a-bambu-lab-printer-over-mqtt-12nb</link>
      <guid>https://dev.to/amsozzer/driving-a-bambu-lab-printer-over-mqtt-12nb</guid>
      <description>&lt;p&gt;A Bambu Lab printer will print in as many colours as you like, provided a human is standing&lt;br&gt;
next to it. Set the change-filament gcode to a pause, slice a multi-colour model, and the&lt;br&gt;
machine will stop at every change and wait for someone to pull one spool out and push the next&lt;br&gt;
one in. That person is the only thing standing between four spools and any number of them.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://amsozzer.com/projects/ams-x" rel="noopener noreferrer"&gt;AMS-X&lt;/a&gt; is an attempt to replace that person. This post is about the part I&lt;br&gt;
had to establish first: what the printer will actually let a program do to it, and how much of&lt;br&gt;
that I have confirmed rather than assumed.&lt;/p&gt;
&lt;h2&gt;
  
  
  The decision that shapes everything else
&lt;/h2&gt;

&lt;p&gt;The tempting approach is to author your own hotend gcode. Retract this much, heat to that,&lt;br&gt;
purge this many millimetres. It is also a trap, because purge and retraction amounts are a&lt;br&gt;
per-nozzle, per-material calibration problem that never ends, and the printer already solves&lt;br&gt;
it.&lt;/p&gt;

&lt;p&gt;So AMS-X writes no hotend gcode at all. It drives the printer's own filament-change routine&lt;br&gt;
and owns only the logistics up to the machine's inlet sensor.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;AMS-X owns&lt;/th&gt;
&lt;th&gt;The printer owns&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;which module, and when&lt;/td&gt;
&lt;td&gt;nozzle temperature&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;retracting the spent filament clear of the path&lt;/td&gt;
&lt;td&gt;retract from the melt zone&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;feeding the next filament to the inlet sensor&lt;/td&gt;
&lt;td&gt;load to nozzle, purge, wipe, resume&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The seam is the printer's existing routine. Everything below is about driving its prompts over&lt;br&gt;
the network instead of by hand.&lt;/p&gt;
&lt;h2&gt;
  
  
  Getting in
&lt;/h2&gt;

&lt;p&gt;The printer runs an MQTT broker on the local network. TLS on 8883, username &lt;code&gt;bblp&lt;/code&gt;, password&lt;br&gt;
is the LAN access code from the printer's own screen. The certificate is self-signed, so&lt;br&gt;
verification is off; this is LAN-only and gated by a code you have to be standing at the&lt;br&gt;
machine to read.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;publish   device/{serial}/request
subscribe device/{serial}/report
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Files go over FTPS, and FTPS is where the time goes. Three things cost me an evening each:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It is &lt;strong&gt;implicit&lt;/strong&gt; TLS on port 990. The connection is encrypted from the first byte. It is
not &lt;code&gt;AUTH TLS&lt;/code&gt; on 21, and a client configured for explicit TLS just hangs.&lt;/li&gt;
&lt;li&gt;The data channel has to &lt;strong&gt;reuse the control connection's TLS session&lt;/strong&gt;. A fresh session gets
refused.&lt;/li&gt;
&lt;li&gt;After &lt;code&gt;STOR&lt;/code&gt;, do &lt;strong&gt;not&lt;/strong&gt; call &lt;code&gt;unwrap()&lt;/code&gt; on the connection. It will sit waiting for a &lt;code&gt;226&lt;/code&gt;
that never comes.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Uploads land in &lt;code&gt;/cache&lt;/code&gt; on the printer's SD card.&lt;/p&gt;

&lt;h2&gt;
  
  
  The plan comes out of the sliced file
&lt;/h2&gt;

&lt;p&gt;A &lt;code&gt;.gcode.3mf&lt;/code&gt; is a zip. Inside it, &lt;code&gt;Metadata/plate_1.gcode&lt;/code&gt; is the actual gcode the printer&lt;br&gt;
will run, and it carries the whole change sequence in order:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Command&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;M400 U1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;pause and wait for a filament change&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;M1020 S&amp;lt;n&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;which project filament is next, zero-indexed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;M620 S&amp;lt;n&amp;gt;A&lt;/code&gt; / &lt;code&gt;M621&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;AMS load and unload, for the AMS path we never use&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;So the server unzips the file it is about to send, reads that gcode top to bottom, and comes&lt;br&gt;
away with an ordered plan: change one wants filament two, change two wants filament five, and&lt;br&gt;
so on. No custom slicer, no parsing of the model itself.&lt;/p&gt;

&lt;p&gt;The plan is only half of it. The other half is the live pause event arriving over MQTT, which&lt;br&gt;
is what says &lt;em&gt;now&lt;/em&gt;. The server matches each pause to the next expected change in its own plan,&lt;br&gt;
which means a stray pause from a human pressing the button on the front never gets mistaken&lt;br&gt;
for a swap. The rule is that the server only acts on a pause it can match to something it&lt;br&gt;
planned.&lt;/p&gt;
&lt;h2&gt;
  
  
  The loop, confirmed on an A1 mini
&lt;/h2&gt;

&lt;p&gt;Every line here has run against real hardware:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;the printer pauses on &lt;code&gt;M400 U1&lt;/code&gt; and the pause shows up in the report stream&lt;/li&gt;
&lt;li&gt;the server sends &lt;code&gt;unload_filament&lt;/code&gt;, and the printer heats the nozzle itself&lt;/li&gt;
&lt;li&gt;a human swaps the spool, because the motorised module is not built yet&lt;/li&gt;
&lt;li&gt;the server watches &lt;code&gt;hw_switch_state&lt;/code&gt;, the printer's own filament-present sensor, until it
trips&lt;/li&gt;
&lt;li&gt;the server sends &lt;code&gt;resume&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;the cursor advances to the next planned change&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;What is worth noticing is how little of that is mine. Two commands and one sensor. The&lt;br&gt;
printer does the heating, the retract from the melt zone, the load, the purge and the wipe,&lt;br&gt;
because those are the parts it already knows how to do.&lt;/p&gt;
&lt;h2&gt;
  
  
  Starting the print
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1. FTPS upload the .gcode.3mf to /cache/&amp;lt;name&amp;gt;
2. project_file with url = file:///sdcard/cache/&amp;lt;name&amp;gt;
3. param = "Metadata/plate_1.gcode"
4. use_ams = false, ams_mapping = []
5. project / profile / task / subtask ids all "0"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Line two is the one that cost me. The obvious URL is &lt;code&gt;ftp:///cache/&amp;lt;name&amp;gt;&lt;/code&gt;, since that is how&lt;br&gt;
the file got there. Sending that faults the A1 with &lt;code&gt;0500_C010&lt;/code&gt;, an SD read/write error, which&lt;br&gt;
tells you nothing about what was actually wrong. It wants a filesystem path,&lt;br&gt;
&lt;code&gt;file:///sdcard/cache/&lt;/code&gt;, not the protocol you uploaded with.&lt;/p&gt;

&lt;p&gt;The ids being &lt;code&gt;"0"&lt;/code&gt; is what makes it a local print. Nothing touches Bambu's cloud.&lt;/p&gt;
&lt;h2&gt;
  
  
  The external spool
&lt;/h2&gt;

&lt;p&gt;The printer models the external spool as a tray with reserved identifiers. Writing to it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ams_id 255, tray_id 254, slot 0
colour as 8-hex RRGGBBAA, alpha appended as FF
tray_info_idx GFL04, nozzle 190–240
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Reading back, &lt;code&gt;vt_tray.tray_color&lt;/code&gt; gives 8-hex and &lt;code&gt;vt_tray.tray_type&lt;/code&gt; gives the material.&lt;br&gt;
&lt;code&gt;layer_num&lt;/code&gt; is reported correctly. &lt;code&gt;mc_print_line_number&lt;/code&gt;, which would be the obvious way to&lt;br&gt;
know where in the gcode a pause happened, reports &lt;code&gt;0&lt;/code&gt; on the A1, so the plan cursor is the&lt;br&gt;
only thing tracking position.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is still a guess
&lt;/h2&gt;

&lt;p&gt;The A1 path above is confirmed. The X1 and P1 driver is not, and I want to be precise about&lt;br&gt;
that: it is &lt;strong&gt;guesses end to end, thirty-one open markers, zero hardware time&lt;/strong&gt;. The unload&lt;br&gt;
trigger, the filament-select command, the resume verb, the start-print shape, all inferred&lt;br&gt;
from the A1 and from what other people have published. One thing I do know is that&lt;br&gt;
&lt;code&gt;ams_filament_setting&lt;/code&gt; returns an empty payload on that path, so it is not supported there.&lt;/p&gt;

&lt;p&gt;Smaller unknowns on the confirmed path:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;gcode_state&lt;/code&gt; and its &lt;code&gt;RUNNING&lt;/code&gt; / &lt;code&gt;PAUSE&lt;/code&gt; / &lt;code&gt;FINISH&lt;/code&gt; values are read but not proven&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;filam_bak&lt;/code&gt;, the X1/P1 filament sensor, is a guessed field name&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;tray_now&lt;/code&gt; is read without being certain what it means&lt;/li&gt;
&lt;li&gt;using &lt;code&gt;print_error&lt;/code&gt; to classify &lt;em&gt;why&lt;/em&gt; a print paused is inference, not documentation&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There is also a &lt;code&gt;routine_extrude()&lt;/code&gt; that pushes filament with &lt;code&gt;M83&lt;/code&gt; and &lt;code&gt;G1 E45 F500&lt;/code&gt;. It is a&lt;br&gt;
diagnostic fallback and deliberately not in the loop, because the loop closes on the sensor&lt;br&gt;
and lets the printer load and purge by itself. Any time I reach for raw gcode, it is a sign&lt;br&gt;
the design has slipped.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why write it down like this
&lt;/h2&gt;

&lt;p&gt;The reason for the &lt;code&gt;[OK]&lt;/code&gt; and &lt;code&gt;[??]&lt;/code&gt; markers in the repo is that a protocol you inferred is a&lt;br&gt;
protocol you will misremember. Six weeks later, everything you wrote looks equally&lt;br&gt;
authoritative, and you cannot tell which lines you watched work and which ones you reasoned&lt;br&gt;
your way into. Separating them costs a column and saves the argument with yourself.&lt;/p&gt;

&lt;p&gt;The A1 is real. The rest is a hypothesis with a test plan attached.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/Amsozzer1/AMS" rel="noopener noreferrer"&gt;AMS-X is on GitHub&lt;/a&gt; under MIT, design documents and all.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://amsozzer.com/writing/driving-a-bambu-lab-printer-over-mqtt" rel="noopener noreferrer"&gt;amsozzer.com&lt;/a&gt;. The ESP32 firmware for the spool modules is at &lt;a href="https://github.com/Amsozzer1/AMS-Firmware" rel="noopener noreferrer"&gt;Amsozzer1/AMS-Firmware&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>mqtt</category>
      <category>3dprinting</category>
      <category>python</category>
      <category>esp32</category>
    </item>
    <item>
      <title>I could not understand Express, so I wrote my own</title>
      <dc:creator>Ahmed Sozzer</dc:creator>
      <pubDate>Wed, 23 Sep 2026 14:06:22 +0000</pubDate>
      <link>https://dev.to/amsozzer/i-could-not-understand-express-so-i-wrote-my-own-12mf</link>
      <guid>https://dev.to/amsozzer/i-could-not-understand-express-so-i-wrote-my-own-12mf</guid>
      <description>&lt;p&gt;&lt;em&gt;Middleware chaining made no sense to me, so I rebuilt it in C++. A year later the profile said the slowest thing in my server was a map I rebuilt on every response.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I do not learn things by reading about them. I can read a page on how something works, nod along, and retain none of it. What stays is what I have had to build, and usually what I have had to build twice.&lt;/p&gt;

&lt;p&gt;So when I joined FYCLabs and found that everything ran on Express, I had a problem. The low-level pieces I was fine with: sockets, buffers, what the kernel is actually doing. Express itself was opaque. Middleware chaining in particular. Handlers calling &lt;code&gt;next()&lt;/code&gt;, error handlers taking four arguments instead of three, ordering that mattered in ways nobody wrote down. I could use it. I could not have told you what it did.&lt;/p&gt;

&lt;p&gt;The sensible response was to read the source. I wrote my own instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  The first attempt was pointless
&lt;/h2&gt;

&lt;p&gt;I started in JavaScript. That lasted about an evening. I was reimplementing the thing I was trying to understand, in the same language, at the same level of abstraction, and learning nothing except that I could produce something Express-shaped if I squinted.&lt;/p&gt;

&lt;p&gt;So I started again in C++, where nothing can be borrowed and every piece has to be built. You cannot hand-wave a socket.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I found in Express
&lt;/h2&gt;

&lt;p&gt;The first real discovery had nothing to do with middleware. It was routing.&lt;/p&gt;

&lt;p&gt;Express matches a request by walking its layer stack in registration order, testing each one until something matches. That is a linear scan, which means the cost of routing depends on where your route happens to sit in the file. I would not have guessed a mature framework had that property.&lt;/p&gt;

&lt;p&gt;A trie made more sense. My first version put one node per character, which is the trie everybody draws first and the wrong one here. URLs are not arbitrary strings. They are a small number of segments separated by slashes. One node per segment, keyed on &lt;code&gt;METHOD:/path&lt;/code&gt;, and a lookup costs one step per segment however many routes exist.&lt;/p&gt;

&lt;p&gt;That difference is most of the performance story against Express. One pinned core, identical handlers, identical payloads:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Routes registered&lt;/th&gt;
&lt;th&gt;PlusWeb&lt;/th&gt;
&lt;th&gt;Express&lt;/th&gt;
&lt;th&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;91,950 rps&lt;/td&gt;
&lt;td&gt;19,236 rps&lt;/td&gt;
&lt;td&gt;4.8×&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1,000&lt;/td&gt;
&lt;td&gt;90,085 rps&lt;/td&gt;
&lt;td&gt;5,639 rps&lt;/td&gt;
&lt;td&gt;16×&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10,000&lt;/td&gt;
&lt;td&gt;89,636 rps&lt;/td&gt;
&lt;td&gt;355 rps&lt;/td&gt;
&lt;td&gt;253×&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The honest row is the first one. A five-route app is what most apps are, and it is the case Express looks best under. The larger multiples are not PlusWeb speeding up, since it sits at about 90,000 across every row. They are Express's router degrading. If you quote one number, quote 4.8×.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where I was wrong for about a year
&lt;/h2&gt;

&lt;p&gt;I wanted to write everything myself. Less ambition than a feeling that using a library for the interesting part was cheating. So I wrote my own event loop, which was not an event loop at all but a while loop that blocked, and my own HTTP parser.&lt;/p&gt;

&lt;p&gt;It took me a year to see why that is a bad instinct. A project that refuses to use anything never reaches a first release. You stop building the thing you set out to build and start maintaining its dependencies instead.&lt;/p&gt;

&lt;p&gt;What broke it was a detour. I had put PlusWeb down and started writing a JavaScript interpreter, which meant reading how Node actually works and trying to imitate V8's parser. Two things came out of that. The first was realising Node is itself a C++ program wrapped around a C event loop: I had gone down a level to understand the thing and found the thing was already there. The second was watching Tsoding mention libuv in passing, which made the question obvious. Node is built on libuv. I am trying to understand Node. Why am I writing my own event loop?&lt;/p&gt;

&lt;p&gt;libuv replaced the blocking loop. When profiling then showed parsing was slow, for several reasons that were entirely my own fault, llhttp replaced my parser. It is the parser Node uses, it is small, and it handles framing as well as parsing, so pipelined requests, chunked bodies and requests split across packets all started working at once. Adopting it closed every correctness gap I had against Express.&lt;/p&gt;

&lt;h2&gt;
  
  
  The slowest thing in my server
&lt;/h2&gt;

&lt;p&gt;Then I profiled properly, expecting the answer to be the blocking loop I had just removed.&lt;/p&gt;

&lt;p&gt;It was a &lt;code&gt;std::map&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;HttpResponse&lt;/code&gt; carried the status-code table, eighty-five entries running from 100 Continue to 598, as a member:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;HttpResponse&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;ResponseCodes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Continue"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="mi"&gt;101&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Switching Protocols"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="c1"&gt;// …eighty-three more&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every response built that map from scratch. Eighty-five string allocations and eighty-five red-black tree insertions per request, to look up one value that never changes. The fix is one word:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;defaultResponseCodes&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I measured it afterwards rather than trusting the memory of it, compiling the commit and its parent with the same flags and running each 200,000 times:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;before&lt;/th&gt;
&lt;th&gt;after&lt;/th&gt;
&lt;th&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;construct an &lt;code&gt;HttpResponse&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;3,390 ns&lt;/td&gt;
&lt;td&gt;10.0 ns&lt;/td&gt;
&lt;td&gt;339×&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;full response path&lt;/td&gt;
&lt;td&gt;3,400 ns&lt;/td&gt;
&lt;td&gt;255 ns&lt;/td&gt;
&lt;td&gt;13×&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Both versions emit byte-identical responses. The constructor is 339 times faster because it went from eighty-five allocations to none. The full path improved 13×, since once the map is gone the remaining work is work that genuinely has to happen.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the time goes now
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;flat %&lt;/th&gt;
&lt;th&gt;symbol&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;~71%&lt;/td&gt;
&lt;td&gt;libc syscall stubs (&lt;code&gt;send&lt;/code&gt;/&lt;code&gt;recv&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3.0%&lt;/td&gt;
&lt;td&gt;&lt;code&gt;llhttp__internal__run&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1.8%&lt;/td&gt;
&lt;td&gt;&lt;code&gt;HttpParser::Impl::onMessageComplete&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.5%&lt;/td&gt;
&lt;td&gt;&lt;code&gt;HttpResponse::serialize&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.5%&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Node::find&lt;/code&gt;, the routing trie&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The router I spent most of my time on is half a percent. The framework as a whole is under ten percent and the rest is the kernel moving bytes. PlusWeb runs at 55% of what a bare epoll server doing no parsing and no routing reaches on the same machine. Express is at 3.9%.&lt;/p&gt;

&lt;p&gt;That is the right place to end up and it is also the end of the cheap wins. Going faster now means making fewer syscalls, not writing faster C++.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it was actually for
&lt;/h2&gt;

&lt;p&gt;I can tell you what Express does when a request arrives, because I have written the thing that does it. Middleware stopped being mysterious the moment I had to implement &lt;code&gt;next()&lt;/code&gt;: it is a list of functions where each one decides whether the next runs, and the four-argument error handler is a second list.&lt;/p&gt;

&lt;p&gt;That was the point. The benchmarks are a side effect, and a slightly misleading one, because they make the project look like it was about speed. It was about not being able to read a page and understand it.&lt;/p&gt;

&lt;p&gt;PlusWeb is &lt;a href="https://github.com/Amsozzer1/PlusWeb" rel="noopener noreferrer"&gt;on GitHub&lt;/a&gt; under MIT, and &lt;a href="https://amsozzer.com/projects/plusweb" rel="noopener noreferrer"&gt;the playground&lt;/a&gt; runs the real router compiled to WebAssembly if you want to watch a trie match something.&lt;/p&gt;

</description>
      <category>cpp</category>
      <category>webdev</category>
      <category>opensource</category>
      <category>showdev</category>
    </item>
  </channel>
</rss>
