<?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: Yusuf İhsan Görgel</title>
    <description>The latest articles on DEV Community by Yusuf İhsan Görgel (@yusufihsangorgel).</description>
    <link>https://dev.to/yusufihsangorgel</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%2F3859744%2Fa9c730c8-0d59-4e68-af52-069001dbe5a7.jpeg</url>
      <title>DEV Community: Yusuf İhsan Görgel</title>
      <link>https://dev.to/yusufihsangorgel</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/yusufihsangorgel"/>
    <language>en</language>
    <item>
      <title>I counted one MCP server on the wire: without the handshake, HTTP stays lighter until call ten</title>
      <dc:creator>Yusuf İhsan Görgel</dc:creator>
      <pubDate>Sun, 11 Oct 2026 13:37:36 +0000</pubDate>
      <link>https://dev.to/yusufihsangorgel/i-counted-one-mcp-server-on-the-wire-without-the-handshake-http-stays-lighter-until-call-ten-4hjp</link>
      <guid>https://dev.to/yusufihsangorgel/i-counted-one-mcp-server-on-the-wire-without-the-handshake-http-stays-lighter-until-call-ten-4hjp</guid>
      <description>&lt;h2&gt;
  
  
  1. What did one flow cost under each revision?
&lt;/h2&gt;

&lt;p&gt;I counted complete flows on loopback to see whether removing initialization and the session identifier reduced a short HTTP session, or moved bytes into request metadata and input retries.&lt;/p&gt;

&lt;p&gt;Plain means tools/list followed by one tools/call. Input means one call whose handler asks the user one question. Ten means tools/list followed by ten calls in one session. The catalog contained three tools.&lt;/p&gt;

&lt;p&gt;Every cross-revision row below is a raw message driver against Python SDK server (mcp 2.2.0). The main flows ran 20 times and the axis variations 5 times, 990 runs over 90 configurations. Every repeat was byte-identical. Each median therefore equals each single run.&lt;/p&gt;

&lt;p&gt;HTTP heads include the request or status line and headers. Body columns exclude chunk framing.&lt;/p&gt;

&lt;p&gt;I wrote this prediction down on 2026-09-30 at 00:14 UTC, five hours before the first counted run, and translated it for this post.&lt;/p&gt;

&lt;blockquote&gt;
&lt;ol&gt;
&lt;li&gt;P1: The plain flow has seven JSON-RPC messages under 2025-11-25 and four under 2026-07-28.&lt;/li&gt;
&lt;li&gt;P2: Each 2026-07-28 request carries 110 to 200 metadata bytes; the old handshake costs 400 to 900 body bytes once. A one-call flow should move fewer body bytes under the new revision.&lt;/li&gt;
&lt;li&gt;P3: After a request-count threshold between two and eight, 2026-07-28 should move more body bytes. A ten-call flow should be heavier.&lt;/li&gt;
&lt;li&gt;P4: Both input flows take four messages per call. The new flow should add 150 to 400 body bytes, with a larger gap as arguments grow.&lt;/li&gt;
&lt;li&gt;P5: New HTTP requests add 70 to 110 bytes of protocol headers each. HTTP framing should exceed half the old plain-flow total, while body differences stay small beside it.&lt;/li&gt;
&lt;li&gt;P6: Stdio adds one newline byte per message, leaving the message body to decide most of the total.&lt;/li&gt;
&lt;li&gt;P7: Fixed request IDs should produce identical bytes across 20 repeats, and any varying field should be found.&lt;/li&gt;
&lt;/ol&gt;
&lt;/blockquote&gt;

&lt;ul&gt;
&lt;li&gt;P1 held: the plain flow counted seven JSON-RPC objects under 2025-11-25 and four under 2026-07-28.&lt;/li&gt;
&lt;li&gt;P2 failed: new HTTP request bodies carried 530 bytes against 353. Measured &lt;code&gt;_meta&lt;/code&gt; spans totaled 377 bytes across two POSTs.&lt;/li&gt;
&lt;li&gt;P3 was about body bytes and failed: the new revision carried more body bytes from the first call. Its second half held: ten calls were heavier. On total HTTP bytes a linear estimate puts the crossover near call ten (section 6).&lt;/li&gt;
&lt;li&gt;P4 held for its range of 150 to 400: the new input flow added 270 body bytes, while its complete HTTP total was 791 bytes lower. I did not vary arguments in the input flow. Its last clause is untested.&lt;/li&gt;
&lt;li&gt;P5 is three claims. Per-request protocol headers were not counted separately. The first is untested. The second held: framing was 60 percent of the old plain total. The third held too: the body difference (177 request bytes) is small beside the framing. Under 2026-07-28 framing fell to 32 percent of the total.&lt;/li&gt;
&lt;li&gt;P6 held: stdio logs contain one newline byte per JSON-RPC message.&lt;/li&gt;
&lt;li&gt;P7 held: summary.tsv marks every repeated run byte-identical across 90 configurations and 990 runs.&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Flow&lt;/th&gt;
&lt;th&gt;Revision&lt;/th&gt;
&lt;th&gt;Server&lt;/th&gt;
&lt;th&gt;JSON-RPC C→S / S→C&lt;/th&gt;
&lt;th&gt;HTTP requests&lt;/th&gt;
&lt;th&gt;Request head&lt;/th&gt;
&lt;th&gt;Request body&lt;/th&gt;
&lt;th&gt;Response head&lt;/th&gt;
&lt;th&gt;Response body&lt;/th&gt;
&lt;th&gt;Chunk bytes&lt;/th&gt;
&lt;th&gt;SSE events&lt;/th&gt;
&lt;th&gt;Total bytes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;plain&lt;/td&gt;
&lt;td&gt;2025-11-25&lt;/td&gt;
&lt;td&gt;Python SDK server (mcp 2.2.0)&lt;/td&gt;
&lt;td&gt;4 / 3&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;821&lt;/td&gt;
&lt;td&gt;353&lt;/td&gt;
&lt;td&gt;990&lt;/td&gt;
&lt;td&gt;866&lt;/td&gt;
&lt;td&gt;34&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;3,064&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;plain&lt;/td&gt;
&lt;td&gt;2026-07-28&lt;/td&gt;
&lt;td&gt;Python SDK server (mcp 2.2.0)&lt;/td&gt;
&lt;td&gt;2 / 2&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;418&lt;/td&gt;
&lt;td&gt;530&lt;/td&gt;
&lt;td&gt;252&lt;/td&gt;
&lt;td&gt;865&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;2,065&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;input&lt;/td&gt;
&lt;td&gt;2025-11-25&lt;/td&gt;
&lt;td&gt;Python SDK server (mcp 2.2.0)&lt;/td&gt;
&lt;td&gt;4 / 3&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;821&lt;/td&gt;
&lt;td&gt;404&lt;/td&gt;
&lt;td&gt;900&lt;/td&gt;
&lt;td&gt;588&lt;/td&gt;
&lt;td&gt;28&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;2,741&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;input&lt;/td&gt;
&lt;td&gt;2026-07-28&lt;/td&gt;
&lt;td&gt;Python SDK server (mcp 2.2.0)&lt;/td&gt;
&lt;td&gt;2 / 2&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;436&lt;/td&gt;
&lt;td&gt;675&lt;/td&gt;
&lt;td&gt;252&lt;/td&gt;
&lt;td&gt;587&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;1,950&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ten&lt;/td&gt;
&lt;td&gt;2025-11-25&lt;/td&gt;
&lt;td&gt;Python SDK server (mcp 2.2.0)&lt;/td&gt;
&lt;td&gt;13 / 12&lt;/td&gt;
&lt;td&gt;13&lt;/td&gt;
&lt;td&gt;2,855&lt;/td&gt;
&lt;td&gt;1,211&lt;/td&gt;
&lt;td&gt;3,420&lt;/td&gt;
&lt;td&gt;1,904&lt;/td&gt;
&lt;td&gt;133&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;9,523&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ten&lt;/td&gt;
&lt;td&gt;2026-07-28&lt;/td&gt;
&lt;td&gt;Python SDK server (mcp 2.2.0)&lt;/td&gt;
&lt;td&gt;11 / 11&lt;/td&gt;
&lt;td&gt;11&lt;/td&gt;
&lt;td&gt;2,371&lt;/td&gt;
&lt;td&gt;3,088&lt;/td&gt;
&lt;td&gt;1,386&lt;/td&gt;
&lt;td&gt;2,721&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;9,566&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The stdio rows below are also from raw driver runs against Python SDK server (mcp 2.2.0). Stdio has no HTTP heads or bodies; newline bytes count line endings in the JSON-RPC stream.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Flow&lt;/th&gt;
&lt;th&gt;Revision&lt;/th&gt;
&lt;th&gt;JSON-RPC C→S / S→C&lt;/th&gt;
&lt;th&gt;Newline bytes&lt;/th&gt;
&lt;th&gt;Total bytes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;plain&lt;/td&gt;
&lt;td&gt;2025-11-25&lt;/td&gt;
&lt;td&gt;4 / 3&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;1,148&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;plain&lt;/td&gt;
&lt;td&gt;2026-07-28&lt;/td&gt;
&lt;td&gt;2 / 2&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;1,399&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;input&lt;/td&gt;
&lt;td&gt;2025-11-25&lt;/td&gt;
&lt;td&gt;4 / 3&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;921&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;input&lt;/td&gt;
&lt;td&gt;2026-07-28&lt;/td&gt;
&lt;td&gt;2 / 2&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;1,266&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ten&lt;/td&gt;
&lt;td&gt;2025-11-25&lt;/td&gt;
&lt;td&gt;13 / 12&lt;/td&gt;
&lt;td&gt;25&lt;/td&gt;
&lt;td&gt;2,828&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ten&lt;/td&gt;
&lt;td&gt;2026-07-28&lt;/td&gt;
&lt;td&gt;11 / 11&lt;/td&gt;
&lt;td&gt;22&lt;/td&gt;
&lt;td&gt;5,831&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The call itself is four JSON-RPC messages under both revisions; the old end-to-end row shows seven because it includes initialize, its result, and notifications/initialized.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A short HTTP flow saved total bytes, while its request bodies grew.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  2. What ran, and what did I leave out?
&lt;/h2&gt;

&lt;p&gt;I measured package:dart_mcp at dart-lang/ai commit cdf9ad1cb50f148f8dd332eaea765e79e5d9b475. That checkout reports 0.6.0-wip; it is not a release. The latest pub.dev release is 0.5.2.&lt;/p&gt;

&lt;p&gt;At this pin, package:dart_mcp serves 2025-11-25 over stdio and 2026-07-28 over Streamable HTTP. Its own rows cannot compare revisions over one transport.&lt;/p&gt;

&lt;p&gt;I used Python SDK server (mcp 2.2.0) to fill those cells; every such table row is labeled.&lt;/p&gt;

&lt;p&gt;I call each JSON-RPC object a message. An HTTP round trip is one request and its response, including a 202 response to a notification. It is a count, not elapsed time.&lt;/p&gt;

&lt;p&gt;A byte is an application byte observed by the relay or stdio tap. HTTP totals include request and response lines, headers, bodies, and chunk framing.&lt;/p&gt;

&lt;p&gt;The relay runs on loopback without TLS. Keepalive frames and retransmissions are excluded; I report no elapsed time.&lt;/p&gt;

&lt;p&gt;The scripts, raw results in results/, and run logs are all in &lt;a href="https://github.com/Yusufihsangorgel/yusufihsangorgel.github.io/tree/83a170383621eb2f2db7e080c837585bb5604d69/mcp-count" rel="noopener noreferrer"&gt;the published instrument folder&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The environment was captured from that checkout:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ sw_vers
ProductName:    macOS
ProductVersion: 26.3
BuildVersion:   25D125
$ uname -m
arm64
$ dart --version
Dart SDK version: 3.11.0 (stable) (Mon Feb 9 00:38:07 2026 -0800) on "macos_arm64"
$ venv/bin/python --version
Python 3.14.3
$ git -C ai rev-parse HEAD
cdf9ad1cb50f148f8dd332eaea765e79e5d9b475
$ grep ^version: ai/pkgs/dart_mcp/pubspec.yaml
version: 0.6.0-wip
$ venv/bin/python -c "import importlib.metadata as m; print(m.version(\"mcp\"))"
2.2.0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I contribute to package:dart_mcp and wrote the multi-version client and server used as the instrument (&lt;a href="https://github.com/dart-lang/ai/pull/676" rel="noopener noreferrer"&gt;PR #676&lt;/a&gt;) and both ends of its Streamable HTTP transport (&lt;a href="https://github.com/dart-lang/ai/pull/572" rel="noopener noreferrer"&gt;PR #572&lt;/a&gt;, &lt;a href="https://github.com/dart-lang/ai/pull/638" rel="noopener noreferrer"&gt;PR #638&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The comparison is scoped to one pinned Dart package, one Python SDK server, and loopback traffic.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  3. What does each revision require on the wire?
&lt;/h2&gt;

&lt;p&gt;For 2025-11-25, the lifecycle specification requires initialization before normal operations:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The client &lt;strong&gt;MUST&lt;/strong&gt; initiate this phase by sending an &lt;code&gt;initialize&lt;/code&gt; request containing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Protocol version supported&lt;/li&gt;
&lt;li&gt;Client capabilities&lt;/li&gt;
&lt;li&gt;Client implementation information&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;

&lt;p&gt;After successful initialization, the client must send an initialized notification. For Streamable HTTP, a server may assign an MCP-Session-Id; if it does, later requests must include it. &lt;a href="https://github.com/modelcontextprotocol/modelcontextprotocol/blob/046fa30efd374370afb87ef830bd788eac5f217e/docs/specification/2025-11-25/basic/lifecycle.mdx#L47-L59" rel="noopener noreferrer"&gt;Lifecycle requirements at the pinned specification commit&lt;/a&gt; &lt;a href="https://github.com/modelcontextprotocol/modelcontextprotocol/blob/046fa30efd374370afb87ef830bd788eac5f217e/docs/specification/2025-11-25/basic/lifecycle.mdx#L147-L153" rel="noopener noreferrer"&gt;Initialized notification rule&lt;/a&gt; &lt;a href="https://github.com/modelcontextprotocol/modelcontextprotocol/blob/046fa30efd374370afb87ef830bd788eac5f217e/docs/specification/2025-11-25/basic/transports.mdx#L198-L220" rel="noopener noreferrer"&gt;HTTP session rules at the pinned specification commit&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;For 2026-07-28, the versioning text says:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;“There is no negotiation handshake. Every request carries its protocol version, and the server accepts or rejects each request independently:”&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;On Streamable HTTP, every POST includes MCP-Protocol-Version and Mcp-Method; tools/call also carries Mcp-Name. Request context travels in &lt;code&gt;_meta&lt;/code&gt;. &lt;a href="https://github.com/modelcontextprotocol/modelcontextprotocol/blob/046fa30efd374370afb87ef830bd788eac5f217e/docs/specification/2026-07-28/basic/versioning.mdx#L12-L13" rel="noopener noreferrer"&gt;Pinned versioning text&lt;/a&gt; &lt;a href="https://github.com/modelcontextprotocol/modelcontextprotocol/blob/046fa30efd374370afb87ef830bd788eac5f217e/docs/specification/2026-07-28/basic/transports/streamable-http.mdx#L244-L297" rel="noopener noreferrer"&gt;Pinned HTTP header rules&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;When a call needs input, the new revision returns input_required and the client retries. If the result contains requestState, the client must echo its exact value on that retry. &lt;a href="https://github.com/modelcontextprotocol/modelcontextprotocol/blob/046fa30efd374370afb87ef830bd788eac5f217e/docs/specification/2026-07-28/basic/patterns/mrtr.mdx#L249-L257" rel="noopener noreferrer"&gt;Pinned retry rule&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The protocol moves request context into each new-revision request and defines a retry for input.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Where did the plain-flow HTTP bytes go?
&lt;/h2&gt;

&lt;p&gt;These exchanges are trimmed from the decoded logs; counts come from complete runs.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Python SDK server (mcp 2.2.0), 2025-11-25
client -&amp;gt; POST initialize
server -&amp;gt; HTTP 200, text/event-stream: initialize result
client -&amp;gt; POST notifications/initialized [MCP-Protocol-Version, Mcp-Session-Id]
server -&amp;gt; HTTP 202 Accepted
client -&amp;gt; POST tools/list [MCP-Protocol-Version, Mcp-Session-Id]
server -&amp;gt; HTTP 200, text/event-stream: tools/list result
client -&amp;gt; POST tools/call [MCP-Protocol-Version, Mcp-Session-Id]
server -&amp;gt; HTTP 200, text/event-stream: tools/call result

Python SDK server (mcp 2.2.0), 2026-07-28
client -&amp;gt; POST tools/list [MCP-Protocol-Version, Mcp-Method, _meta]
server -&amp;gt; HTTP 200, application/json: tools/list result
client -&amp;gt; POST tools/call [MCP-Protocol-Version, Mcp-Method, Mcp-Name, _meta]
server -&amp;gt; HTTP 200, application/json: tools/call result
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For 2025-11-25, request heads, response heads, and chunk framing sum to 1,845 bytes of 3,064: (821 + 990 + 34) / 3,064, or 60 percent.&lt;/p&gt;

&lt;p&gt;For 2026-07-28, they sum to 670 bytes of 2,065: (418 + 252 + 0) / 2,065, or 32 percent.&lt;/p&gt;

&lt;p&gt;The new request bodies grew from 353 to 530 bytes. Response bodies were nearly equal, at 866 and 865 bytes. Four HTTP requests and three SSE events became two requests and no SSE events.&lt;/p&gt;

&lt;p&gt;Two things moved those bytes. Two fewer requests took request heads from 821 to 418 bytes. Response heads fell from 990 to 252: two fewer responses, and this server answers 2025-11-25 calls with an SSE stream and 2026-07-28 calls with plain JSON. The older spec allows either answer. Part of that saving is a server choice.&lt;/p&gt;

&lt;p&gt;The raw driver sends no DELETE when an old-revision session ends; the spec says clients SHOULD. A client that sends one adds a fifth request to the old rows. Under 2026-07-28 there is no session id and nothing to end with a DELETE. &lt;a href="https://github.com/modelcontextprotocol/modelcontextprotocol/blob/046fa30efd374370afb87ef830bd788eac5f217e/docs/specification/2026-07-28/changelog.mdx#L12-L16" rel="noopener noreferrer"&gt;The pinned changelog removes protocol-level sessions&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;For one new-revision HTTP flow, the raw driver with the Dart server moved 2,211 bytes; the Python SDK server row moved 2,065. The package client row moved 2,394.&lt;/p&gt;

&lt;p&gt;Its request head was 574 bytes against 418 for the raw driver, and its chunk framing was 46 bytes against 23.&lt;/p&gt;

&lt;p&gt;On stdio, the Python SDK server plain flow moved 1,148 bytes under 2025-11-25 and 1,399 under 2026-07-28. There is no HTTP head layer there.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;On this server, fewer requests and JSON answers instead of SSE covered the larger request bodies.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  5. What happens when a tool asks for an answer?
&lt;/h2&gt;

&lt;p&gt;Under 2025-11-25, the server sends elicitation/create on the SSE response opened by tools/call. The client posts its answer on a new request tied to the session. Under revision 2026-07-28, input_required comes back as JSON, and the client retries tools/call with its original arguments and inputResponses.&lt;/p&gt;

&lt;p&gt;The traces and numbers in this section are the raw driver against Python SDK server (mcp 2.2.0). The tool here takes no requestState. The arrows in both images label body bytes; the totals also include HTTP lines and headers.&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%2Fg70ki83roqf585eg2j4j.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%2Fg70ki83roqf585eg2j4j.png" alt="1. client to server: initialize · POST · 183 B; 2. server to client: result · 200 · SSE 216 B; 3. client to server: notifications/initialized · POST · 54 B; 4. server to client: 202 · 0 B; 5. client to server: tools/call · POST · 87 B; 6. server to client: elicitation/create · 200 · SSE 244 B; 7. client to server: result · POST · 80 B; 8. server to client: 202 · 0 B; 9. server to client: result · 200 · SSE 128 B" width="800" height="808"&gt;&lt;/a&gt;&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%2F62t0k1abjaw2xnpbg814.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%2F62t0k1abjaw2xnpbg814.png" alt="1. client to server: tools/call · POST · 301 B; 2. server to client: input_required · 200 · 368 B; 3. client to server: tools/call · POST · 374 B; 4. server to client: result · 200 · 219 B" width="800" height="499"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In logs for revision 2026-07-28, both POSTs are tools/call. The retry repeats the empty arguments and adds the accepted answer. The full HTTP input total fell from 2,741 to 1,950 bytes.&lt;/p&gt;

&lt;p&gt;Request bodies alone rose from 404 to 675 bytes, a 271-byte increase. Combined request and response bodies rose by 270 bytes because response bodies fell from 588 to 587.&lt;/p&gt;

&lt;p&gt;Under 2025-11-25, the flow sent three SSE events and used four HTTP requests. The new flow sent no SSE events and used two requests.&lt;/p&gt;

&lt;p&gt;package:dart_mcp does the same job on both revisions in its own way. &lt;a href="https://github.com/dart-lang/ai/pull/654" rel="noopener noreferrer"&gt;PR #654&lt;/a&gt; routes server-to-client requests on older revisions, and &lt;a href="https://github.com/dart-lang/ai/pull/655" rel="noopener noreferrer"&gt;PR #655&lt;/a&gt; removed elicit, listRoots and createMessage from the server. A &lt;a href="https://github.com/dart-lang/ai/blob/cdf9ad1cb50f148f8dd332eaea765e79e5d9b475/pkgs/dart_mcp/lib/src/server/legacy_input_required_shim.dart" rel="noopener noreferrer"&gt;compatibility shim&lt;/a&gt; turns an input-required result into the older server request. On the client, &lt;a href="https://github.com/dart-lang/ai/pull/632" rel="noopener noreferrer"&gt;PR #632&lt;/a&gt; answers the input requests in an input_required result, and &lt;a href="https://github.com/dart-lang/ai/pull/637" rel="noopener noreferrer"&gt;PR #637&lt;/a&gt; lets handlers return one. None of the Dart code is in this section's numbers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The retry enlarged request bodies while the complete input flow moved fewer HTTP bytes.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  6. At what call count does the HTTP total cross?
&lt;/h2&gt;

&lt;p&gt;Using Python SDK server (mcp 2.2.0) plain-flow totals, I estimated a fixed session cost and marginal bytes per added call from the measured one-call and ten-call endpoints. The fixed term includes tools/list and session setup.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Revision&lt;/th&gt;
&lt;th&gt;One-call total&lt;/th&gt;
&lt;th&gt;Ten-call total&lt;/th&gt;
&lt;th&gt;Fixed bytes per session&lt;/th&gt;
&lt;th&gt;Marginal bytes per added call&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;2025-11-25&lt;/td&gt;
&lt;td&gt;3,064&lt;/td&gt;
&lt;td&gt;9,523&lt;/td&gt;
&lt;td&gt;2,346.3&lt;/td&gt;
&lt;td&gt;717.7&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2026-07-28&lt;/td&gt;
&lt;td&gt;2,065&lt;/td&gt;
&lt;td&gt;9,566&lt;/td&gt;
&lt;td&gt;1,231.6&lt;/td&gt;
&lt;td&gt;833.4&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Only one call and ten calls were measured. If each added call costs the same, the totals meet at 9.63 calls: sessions of up to nine calls would be lighter under 2026-07-28. That middle part is an estimate. The measured ten-call totals were 9,566 against 9,523, 43 bytes heavier under the new revision.&lt;/p&gt;

&lt;p&gt;Stdio goes the other way from the short HTTP result. At one call, the new revision moved 1,399 bytes against 1,148.&lt;/p&gt;

&lt;p&gt;At ten calls, it moved 5,831 against 2,828. That gap is consistent with the per-request &lt;code&gt;_meta&lt;/code&gt;: 377 bytes over both new-revision requests of the plain flow, 2,078 over eleven requests in the ten-call flow.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;On HTTP, the short-flow lead narrows until the measured ten-call total is 43 bytes higher.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  7. What does carried request state do to the retry?
&lt;/h2&gt;

&lt;p&gt;This separate input-and-state flow uses the raw driver against Python SDK server (mcp 2.2.0). It calls a tool that does some work, asks for a confirmation, and carries a request state of the configured size. Its tool and arguments differ from section 5, which is why its zero-state totals (2,765 and 2,057) differ from the input rows there (2,741 and 1,950). The old revision has no requestState. Only the new rows grow with the state size.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;State bytes&lt;/th&gt;
&lt;th&gt;Revision&lt;/th&gt;
&lt;th&gt;Total bytes&lt;/th&gt;
&lt;th&gt;Request body bytes&lt;/th&gt;
&lt;th&gt;Response body bytes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;2025-11-25&lt;/td&gt;
&lt;td&gt;2,765&lt;/td&gt;
&lt;td&gt;429&lt;/td&gt;
&lt;td&gt;586&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;2026-07-28&lt;/td&gt;
&lt;td&gt;2,057&lt;/td&gt;
&lt;td&gt;745&lt;/td&gt;
&lt;td&gt;606&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2,048&lt;/td&gt;
&lt;td&gt;2025-11-25&lt;/td&gt;
&lt;td&gt;2,771&lt;/td&gt;
&lt;td&gt;432&lt;/td&gt;
&lt;td&gt;589&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2,048&lt;/td&gt;
&lt;td&gt;2026-07-28&lt;/td&gt;
&lt;td&gt;6,164&lt;/td&gt;
&lt;td&gt;2,799&lt;/td&gt;
&lt;td&gt;2,657&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The specification treats requestState as opaque. When an input_required result contains it, the client must return the exact value with its retry. &lt;a href="https://github.com/modelcontextprotocol/modelcontextprotocol/blob/046fa30efd374370afb87ef830bd788eac5f217e/docs/specification/2026-07-28/basic/patterns/mrtr.mdx#L249-L257" rel="noopener noreferrer"&gt;Pinned retry text&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;With no state, flow under revision 2026-07-28 moved 2,057 bytes against 2,765 under 2025-11-25. With 2,048 state bytes, it moved 6,164 against 2,771.&lt;/p&gt;

&lt;p&gt;The response delivers state to the client, and the retry sends it back with the answer.&lt;/p&gt;

&lt;p&gt;This is the Python SDK server's configured state representation, measured on loopback with this raw driver. It does not establish a universal encoding cost for requestState.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;With a 2,048-byte state string on this server, the retry cost more than the saved setup.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  8. How were the bytes counted?
&lt;/h2&gt;

&lt;p&gt;The relay forwards socket data unchanged in both directions and logs each chunk as base64. It does not parse HTTP or add TLS.&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;#!/usr/bin/env python3
&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;TCP relay: copies bytes unchanged in both directions, logs each chunk as one JSON line.

Usage: relay.py --listen HOST:PORT --upstream HOST:PORT --log FILE
&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;argparse&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;base64&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;contextlib&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;selectors&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;signal&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;socket&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;hostport&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;port&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;rpartition&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;return&lt;/span&gt; &lt;span class="n"&gt;host&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;port&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;main&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;ap&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;argparse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ArgumentParser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;__doc__&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;ap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_argument&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--listen&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;required&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="nb"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;hostport&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;ap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_argument&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&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;required&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="nb"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;hostport&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;ap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_argument&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="n"&gt;required&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;args&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse_args&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="n"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SIGTERM&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;default_int_handler&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# same path as Ctrl-C
&lt;/span&gt;    &lt;span class="n"&gt;sel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;selectors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;DefaultSelector&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;server&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_server&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;listen&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;sel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;register&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;server&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;selectors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;EVENT_READ&lt;/span&gt;&lt;span class="p"&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;log&lt;/span&gt; &lt;span class="o"&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;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;w&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;seq&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;conns&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="n"&gt;open_dirs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;  &lt;span class="c1"&gt;# connection number -&amp;gt; directions still open
&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;listening on %s:%d&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="n"&gt;server&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getsockname&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;span class="nb"&gt;file&lt;/span&gt;&lt;span class="o"&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;stderr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;flush&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;def&lt;/span&gt; &lt;span class="nf"&gt;record&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;direction&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;fields&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;nonlocal&lt;/span&gt; &lt;span class="n"&gt;seq&lt;/span&gt;
        &lt;span class="n"&gt;seq&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;  &lt;span class="c1"&gt;# one counter shared by all connections
&lt;/span&gt;        &lt;span class="n"&gt;log&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;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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;conn&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;seq&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;seq&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;dir&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;direction&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;fields&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="sh"&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;log&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;flush&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="k"&gt;while&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;for&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;_&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;sel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select&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;key&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="c1"&gt;# new client: open the matching upstream connection
&lt;/span&gt;                    &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;server&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;accept&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
                    &lt;span class="n"&gt;upstream&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_connection&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;upstream&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                    &lt;span class="n"&gt;conns&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
                    &lt;span class="n"&gt;open_dirs&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;conns&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;
                    &lt;span class="n"&gt;sel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;register&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="n"&gt;selectors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;EVENT_READ&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;conns&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;c2s&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;upstream&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
                    &lt;span class="n"&gt;sel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;register&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;upstream&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;selectors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;EVENT_READ&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;conns&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;s2c&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="k"&gt;continue&lt;/span&gt;
                &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;direction&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;peer&lt;/span&gt; &lt;span class="o"&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;data&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;data&lt;/span&gt; &lt;span class="o"&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;fileobj&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;65536&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="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;b&lt;/span&gt;&lt;span class="sh"&gt;""&lt;/span&gt;
                &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                    &lt;span class="nf"&gt;record&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;direction&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b64&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;base64&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;b64encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
                    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;contextlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;suppress&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;OSError&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
                        &lt;span class="n"&gt;peer&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;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                    &lt;span class="k"&gt;continue&lt;/span&gt;
                &lt;span class="nf"&gt;record&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;direction&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;eof&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;sel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;unregister&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;fileobj&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;contextlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;suppress&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;OSError&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
                    &lt;span class="n"&gt;peer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;shutdown&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SHUT_WR&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# pass the end of stream along
&lt;/span&gt;                &lt;span class="n"&gt;open_dirs&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;-=&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;open_dirs&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&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;fileobj&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;peer&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="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;KeyboardInterrupt&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;finally&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;log&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;server&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="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_map&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;values&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;fileobj&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="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;__name__&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;__main__&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The HTTP counter reconstructs streams from logged chunks and separates heads, bodies, chunk framing, SSE events, and JSON-RPC messages. It reads logs without forwarding traffic or rewriting bytes. &lt;a href="https://github.com/Yusufihsangorgel/yusufihsangorgel.github.io/blob/83a170383621eb2f2db7e080c837585bb5604d69/mcp-count/tools/count.py" rel="noopener noreferrer"&gt;Published count.py&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The stdio tap spawns a server child, copies stdin and stdout byte-for-byte, and logs chunks in the relay format. It leaves stderr inherited and does not inspect payloads or use sockets. &lt;a href="https://github.com/Yusufihsangorgel/yusufihsangorgel.github.io/blob/83a170383621eb2f2db7e080c837585bb5604d69/mcp-count/raw/tee_stdio.py" rel="noopener noreferrer"&gt;Published tee_stdio.py&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The stdio counter reconstructs both pipe streams and counts complete newline-terminated lines as JSON-RPC messages. It does not parse HTTP and measures &lt;code&gt;_meta&lt;/code&gt; only on client-to-server 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="c1"&gt;#!/usr/bin/env python3
&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Summarise a stdio relay log (one JSON-RPC message per line) as the same TSV row as count.py.

Usage: count_stdio_log.py LOG [--label LABEL] [--header]
The log has the relay format; &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;conn&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; may be missing. Every chunk that ends with a newline
byte holds whole messages, and each newline byte is counted in newline_bytes.
&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;argparse&lt;/span&gt;

&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;count&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;COLUMNS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;emit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;load_log&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;meta_bytes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rpc_count&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;ap&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;argparse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ArgumentParser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Summarise one stdio log as a TSV row.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;ap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_argument&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="n"&gt;ap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_argument&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--label&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;run&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;ap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_argument&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--header&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;store_true&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;help&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;print the header line first&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;args&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse_args&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;streams&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;load_log&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;log&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;row&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromkeys&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;COLUMNS&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="nf"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;direction&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;buf&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;streams&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;()):&lt;/span&gt;
        &lt;span class="n"&gt;lines&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;buf&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="sa"&gt;b&lt;/span&gt;&lt;span class="sh"&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="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="c1"&gt;# text after the last newline is not a message yet
&lt;/span&gt;        &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;newline_bytes&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lines&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;lines&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;jsonrpc_&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;direction&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nf"&gt;rpc_count&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="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;direction&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;c2s&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;meta_bytes_c2s&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nf"&gt;meta_bytes&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="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="o"&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;label&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mcp_headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;none&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;total_bytes&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;emit&lt;/span&gt;&lt;span class="p"&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;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;header&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;__name__&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;__main__&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The relay preserves application bytes; the counters classify the captured streams.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  9. What did I catch before counting?
&lt;/h2&gt;

&lt;p&gt;The matrix runner is Python, and it starts the Dart client with a state size. My first version built that argument as &lt;code&gt;state_bytes or 256&lt;/code&gt;. Python treats zero as false. A zero-state run would have carried 256 state bytes under a label that said zero.&lt;/p&gt;

&lt;p&gt;I caught it while writing the runner, before any counted run, and changed it to pass the configured integer as it is. The rows labeled 256 in the results are a separate, deliberate setting.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The runner error was fixed before it entered results.tsv.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  10. What can these numbers not tell me?
&lt;/h2&gt;

&lt;p&gt;Bytes are not tokens. I measured application bytes on loopback, without TLS or a model in the path. I did not measure elapsed time, retransmissions, keepalive frames, or IP packet bytes.&lt;/p&gt;

&lt;p&gt;The cache-hint re-list was not measured.&lt;/p&gt;

&lt;p&gt;I did not open the subscriptions/listen stream described by &lt;a href="https://github.com/dart-lang/ai/pull/636" rel="noopener noreferrer"&gt;PR #636&lt;/a&gt;, or measure request-triggered notifications/message from &lt;a href="https://github.com/dart-lang/ai/pull/595" rel="noopener noreferrer"&gt;PR #595&lt;/a&gt;. tools/list returned one page; &lt;a href="https://github.com/dart-lang/ai/pull/682" rel="noopener noreferrer"&gt;PR #682&lt;/a&gt; adds page walking.&lt;/p&gt;

&lt;p&gt;A client helper for initializing a server across versions later landed on main in &lt;a href="https://github.com/dart-lang/ai/pull/690" rel="noopener noreferrer"&gt;PR #690&lt;/a&gt;. I did not measure it.&lt;/p&gt;

&lt;p&gt;The prior-work scan found two posts counting request-body bytes for one tools/call across revisions, both against 2025-06-18, and an SDK issue counting HTTP requests and TCP connections.&lt;/p&gt;

&lt;p&gt;I could not find a counted whole-flow comparison against 2025-11-25 with message counts, HTTP round trips, request and response bytes, and header bytes from an SDK. Links welcome: &lt;a href="https://rizz.dev/feed/every-stateless-mcp-server-rebuilds-state" rel="noopener noreferrer"&gt;rizz.dev&lt;/a&gt;, &lt;a href="https://dev.to/amitrix/mcp-became-stateless-migrating-a-real-agentcore-gateway-4f9l"&gt;amitrix on dev.to&lt;/a&gt;, and the &lt;a href="https://github.com/modelcontextprotocol/python-sdk/issues/3281" rel="noopener noreferrer"&gt;SDK issue&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;A &lt;a href="https://news.ycombinator.com/item?id=49779329" rel="noopener noreferrer"&gt;Hacker News discussion&lt;/a&gt; argues about protocol overhead. This notebook counts bytes for the stated flows on loopback.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;These totals describe one pinned implementation, one server, and one transport at a time.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  11. How do I reproduce one row?
&lt;/h2&gt;

&lt;p&gt;Clone the published instrument at its pinned commit, then follow its README build and one-configuration run. I ran these steps from an empty directory before publishing; the full matrix reproduced all 1,800 median cells.&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 &lt;span class="nt"&gt;--filter&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;blob:none &lt;span class="nt"&gt;--no-checkout&lt;/span&gt; https://github.com/Yusufihsangorgel/yusufihsangorgel.github.io site
git &lt;span class="nt"&gt;-C&lt;/span&gt; site sparse-checkout &lt;span class="nb"&gt;set &lt;/span&gt;mcp-count
git &lt;span class="nt"&gt;-C&lt;/span&gt; site checkout 83a170383621eb2f2db7e080c837585bb5604d69
&lt;span class="nb"&gt;cp&lt;/span&gt; &lt;span class="nt"&gt;-R&lt;/span&gt; site/mcp-count ~/mcp-count &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd&lt;/span&gt; ~/mcp-count

git clone https://github.com/dart-lang/ai ai
git &lt;span class="nt"&gt;-C&lt;/span&gt; ai checkout cdf9ad1cb50f148f8dd332eaea765e79e5d9b475
&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;cd &lt;/span&gt;ai/pkgs/dart_mcp &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; dart pub get&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;cd &lt;/span&gt;dart &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; dart pub get &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; dart analyze&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; build
&lt;span class="k"&gt;for &lt;/span&gt;n &lt;span class="k"&gt;in &lt;/span&gt;server client_stdio client_http&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;cd &lt;/span&gt;dart &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; dart compile exe bin/&lt;span class="nv"&gt;$n&lt;/span&gt;.dart &lt;span class="nt"&gt;-o&lt;/span&gt; ../build/&lt;span class="nv"&gt;$n&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;done

&lt;/span&gt;uv venv &lt;span class="nt"&gt;--python&lt;/span&gt; 3.14 venv
uv pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--python&lt;/span&gt; venv/bin/python &lt;span class="nt"&gt;-r&lt;/span&gt; pysdk/requirements.txt

python3 run_matrix.py &lt;span class="nt"&gt;--dart-build&lt;/span&gt; build &lt;span class="nt"&gt;--py&lt;/span&gt; venv/bin/python &lt;span class="nt"&gt;--py-server&lt;/span&gt; pysdk/server.py &lt;span class="nt"&gt;--out&lt;/span&gt; out-one &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--only&lt;/span&gt; pkg-dart-http-2026-07-28-plain-cion-none-a0-s0-c3
python3 tools/median.py out-one/results.tsv | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="nt"&gt;-F&lt;/span&gt; &lt;span class="s1"&gt;'\t'&lt;/span&gt; &lt;span class="s1"&gt;'$2 == "total_bytes"'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Expected output from results/medians.tsv:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;pkg-dart-http-2026-07-28-plain-cion-none-a0-s0-c3   total_bytes 2394    2394    2394
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The pinned one-configuration command regenerates the package HTTP total_bytes row.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  12. What should another SDK measure?
&lt;/h2&gt;

&lt;p&gt;If you maintain another MCP SDK, run plain, input, and ten-call flows on every transport it supports. Please share the server and revision with the same message, request, header, body, framing, event, and total columns.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Response format and per-request metadata both moved these totals; another server may answer differently.&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>dart</category>
      <category>http</category>
    </item>
    <item>
      <title>Why one failed font load kept breaking pendingFonts() in Flutter</title>
      <dc:creator>Yusuf İhsan Görgel</dc:creator>
      <pubDate>Thu, 24 Sep 2026 16:05:05 +0000</pubDate>
      <link>https://dev.to/yusufihsangorgel/why-one-failed-font-load-kept-breaking-pendingfonts-in-flutter-3o6g</link>
      <guid>https://dev.to/yusufihsangorgel/why-one-failed-font-load-kept-breaking-pendingfonts-in-flutter-3o6g</guid>
      <description>&lt;p&gt;In google_fonts 8.2.0, one failed font load stayed in the set that &lt;code&gt;GoogleFonts.pendingFonts()&lt;/code&gt; waits on, and every later call threw.&lt;/p&gt;

&lt;p&gt;It kept throwing after a retry loaded the same font.&lt;/p&gt;

&lt;p&gt;If your widget tests or your startup code await &lt;code&gt;pendingFonts()&lt;/code&gt; on 8.2.0, one bad download is enough. Version 8.2.1 stops the failure from sticking.&lt;/p&gt;

&lt;p&gt;A google_fonts user, &lt;a class="mentioned-user" href="https://dev.to/bramp"&gt;@bramp&lt;/a&gt;, reported it in &lt;a href="https://github.com/flutter/flutter/issues/182430" rel="noopener noreferrer"&gt;issue #182430&lt;/a&gt; in February, named the cause and proposed the fix: &lt;code&gt;whenComplete&lt;/code&gt; with &lt;code&gt;catchError&lt;/code&gt;, plus a test for it. I applied it in &lt;a href="https://github.com/flutter/packages/pull/12240" rel="noopener noreferrer"&gt;flutter/packages#12240&lt;/a&gt; with &lt;code&gt;ignore()&lt;/code&gt; in place of &lt;code&gt;catchError&lt;/code&gt; and a regression test, released as version 8.2.1 on July 31.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a test sees
&lt;/h2&gt;

&lt;p&gt;The test in the last section calls &lt;code&gt;GoogleFonts.lato()&lt;/code&gt;, then awaits &lt;code&gt;pendingFonts()&lt;/code&gt; three times, each call inside &lt;code&gt;try/catch&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;In this test &lt;code&gt;TestWidgetsFlutterBinding&lt;/code&gt; answers the default HTTP client with status 400, and the font download fails without any mock. The output blocks below are excerpts of the recorded runs.&lt;/p&gt;

&lt;p&gt;On 8.2.0 all three runs exited with code 1. The test failed on an uncaught error:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;00:00 +0 -1: pendingFonts after a failed load [E]
  Exception: Failed to load font with url: https://fonts.gstatic.com/s/a/5ced1045820caaac87af3f61ded09bacc90881f158d04c59dcce436fd02ad368.ttf
  package:google_fonts/src/google_fonts_base.dart 275:5            _httpFetchFontAndSaveToDevice
  ===== asynchronous gap ===========================
  dart:async                                                       _CustomZone.registerBinaryCallback
  package:google_fonts/src/google_fonts_base.dart 144:23           loadFontIfNecessary
  package:google_fonts/src/google_fonts_base.dart 107:38           googleFontsTextStyle
  package:google_fonts/src/google_fonts_parts/part_l.dart 1375:12  PartL.lato
  test/pending_fonts_test.dart 11:21                               main.&amp;lt;fn&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After the test binding's warning about status 400, the output ends with all three calls throwing, although each one was caught:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;first pendingFonts(): threw _Exception
second pendingFonts(): threw _Exception
third pendingFonts(): threw _Exception
00:00 +0 -1: Some tests failed.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On 8.2.1 all three runs exited with code 0. The first call still threw, because that load really failed. The later calls completed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;first pendingFonts(): threw _Exception
second pendingFonts(): completed
third pendingFonts(): completed
00:00 +1: All tests passed!
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;On 8.2.1 a failed load fails the calls already waiting for it, and none that come after it.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A completed &lt;code&gt;pendingFonts()&lt;/code&gt; means no failed load is still pending. It does not mean the font is available: the first call still sees the failure, and only another request for the font retries it.&lt;/p&gt;

&lt;h2&gt;
  
  
  One line, two problems
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;pendingFontFutures&lt;/code&gt; is a top-level set of loading futures, and &lt;a href="https://github.com/flutter/packages/blob/ac87e65333e1159022267053c009f747667f6f50/packages/google_fonts/lib/src/google_fonts_all_parts.dart#L110" rel="noopener noreferrer"&gt;&lt;code&gt;pendingFonts()&lt;/code&gt;&lt;/a&gt; is &lt;code&gt;Future.wait(pendingFontFutures)&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The production-code change is one line (&lt;a href="https://github.com/flutter/packages/blob/ac87e65333e1159022267053c009f747667f6f50/packages/google_fonts/lib/src/google_fonts_base.dart#L107-L109" rel="noopener noreferrer"&gt;lines 107-109&lt;/a&gt; after the fix); the pull request also updated the set's doc comment and added a test:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;   final Future&amp;lt;void&amp;gt; loadingFuture = loadFontIfNecessary(descriptor);
   pendingFontFutures.add(loadingFuture);
&lt;span class="gd"&gt;-  loadingFuture.then((_) =&amp;gt; pendingFontFutures.remove(loadingFuture));
&lt;/span&gt;&lt;span class="gi"&gt;+  loadingFuture.whenComplete(() =&amp;gt; pendingFontFutures.remove(loadingFuture)).ignore();
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In 8.2.0 that &lt;code&gt;then&lt;/code&gt; callback is the only code that removes anything from the set: the library names the set in four places (its declaration, the &lt;code&gt;add&lt;/code&gt;, this &lt;code&gt;remove&lt;/code&gt; and the &lt;code&gt;Future.wait&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;A &lt;code&gt;then&lt;/code&gt; callback without &lt;code&gt;onError&lt;/code&gt; runs only when the future succeeds. A failed load stays in the set, and every later &lt;code&gt;Future.wait&lt;/code&gt; over the set fails. That is the cause &lt;a class="mentioned-user" href="https://dev.to/bramp"&gt;@bramp&lt;/a&gt;'s issue named.&lt;/p&gt;

&lt;p&gt;The same line has a second problem. &lt;a href="https://api.dart.dev/dart-async/Future/then.html" rel="noopener noreferrer"&gt;&lt;code&gt;then&lt;/code&gt;&lt;/a&gt; returns a new future, and without &lt;code&gt;onError&lt;/code&gt; the error "is forwarded directly to the returned future."&lt;/p&gt;

&lt;p&gt;Nothing listens to that returned future. Its error is uncaught, and &lt;code&gt;flutter test&lt;/code&gt; fails the test for it, even when every &lt;code&gt;pendingFonts()&lt;/code&gt; call is caught.&lt;/p&gt;

&lt;p&gt;In 8.2.1, &lt;a href="https://api.dart.dev/dart-async/Future/whenComplete.html" rel="noopener noreferrer"&gt;&lt;code&gt;whenComplete&lt;/code&gt;&lt;/a&gt; runs the removal on success and on error. Its returned future fails the same way as the load, and &lt;a href="https://api.dart.dev/dart-async/FutureExtensions/ignore.html" rel="noopener noreferrer"&gt;&lt;code&gt;ignore()&lt;/code&gt;&lt;/a&gt; handles that error. Callers of &lt;code&gt;pendingFonts()&lt;/code&gt; still get the load's own failure.&lt;/p&gt;

&lt;h2&gt;
  
  
  Each half on its own
&lt;/h2&gt;

&lt;p&gt;Five versions of line 109 were measured. Each ran the pull request's test and a retry test three times, with the same result every time (&lt;code&gt;remove&lt;/code&gt; is the removal callback):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;line 109&lt;/th&gt;
&lt;th&gt;
&lt;code&gt;pendingFonts()&lt;/code&gt; after the failure&lt;/th&gt;
&lt;th&gt;uncaught error&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;.then(remove)&lt;/code&gt; (8.2.0)&lt;/td&gt;
&lt;td&gt;throw the old error&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;.then(remove).ignore()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;throw the old error&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;.whenComplete(remove)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;complete&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;.whenComplete(remove).ignore()&lt;/code&gt; (8.2.1)&lt;/td&gt;
&lt;td&gt;complete&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;.whenComplete(remove).catchError((_) {})&lt;/code&gt; (the issue's version)&lt;/td&gt;
&lt;td&gt;complete&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&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%2Fvfq4ng3sfoh0jwe8bbev.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%2Fvfq4ng3sfoh0jwe8bbev.png" alt="Four forms of line 109: then leaves later pendingFonts() calls throwing, and a missing ignore() leaves an uncaught error" width="800" height="433"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Each problem shows up as its own error report in the test output. With &lt;code&gt;.whenComplete(remove)&lt;/code&gt; alone the set is clean, and the pull request's test still fails on the uncaught error:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;00:00 +0 -1: pendingFonts removes failed font loads [E]
  Exception: Failed to load font with url: https://fonts.gstatic.com/s/a/1194f6ffe4d2f05258573616a77932c38041f3102763096c19437c3db1818a04.ttf
  package:google_fonts/src/google_fonts_base.dart 275:5   _httpFetchFontAndSaveToDevice
  ===== asynchronous gap ===========================
  dart:async                                              _CustomZone.registerBinaryCallback
  package:google_fonts/src/google_fonts_base.dart 152:9   loadFontIfNecessary
  package:google_fonts/src/google_fonts_base.dart 107:38  googleFontsTextStyle
  test/load_font_if_necessary_test.dart 374:5             main.&amp;lt;fn&amp;gt;

00:00 +0 -1: Some tests failed.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;code&gt;whenComplete&lt;/code&gt; cleans the set and &lt;code&gt;ignore()&lt;/code&gt; handles the future it returns. Drop either half and one failure comes back.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The issue's version used &lt;code&gt;catchError((_) {})&lt;/code&gt; where 8.2.1 uses &lt;code&gt;ignore()&lt;/code&gt;. It passed the same tests, and these tests do not tell them apart.&lt;/p&gt;

&lt;p&gt;The pull request's own test fails at the parent commit in three runs out of three and passes at the merge commit. The whole test file goes from 11 passed and 1 failed to 12 passed.&lt;/p&gt;

&lt;h2&gt;
  
  
  On 8.2.0, a successful retry does not help
&lt;/h2&gt;

&lt;p&gt;When a load fails, &lt;code&gt;loadFontIfNecessary&lt;/code&gt; forgets the font, and a later request for the same font tries to load it again.&lt;/p&gt;

&lt;p&gt;The retry test answers 404 first, then 200, and requests the same font twice. The retry's own future completes.&lt;/p&gt;

&lt;p&gt;With a &lt;code&gt;then&lt;/code&gt; line one future is still in the set, and &lt;code&gt;pendingFonts()&lt;/code&gt; throws the first error again. The output below is from &lt;code&gt;.then(remove).ignore()&lt;/code&gt;, to keep it to one error report:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;retry loaded; pending futures: 1
00:00 +0 -1: pendingFonts after a failed load and a successful retry [E]
  Exception: Failed to load font with url: https://fonts.gstatic.com/s/a/1194f6ffe4d2f05258573616a77932c38041f3102763096c19437c3db1818a04.ttf
  package:google_fonts/src/google_fonts_base.dart 275:5  _httpFetchFontAndSaveToDevice

00:00 +0 -1: Some tests failed.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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%2F1491okxsbh238qypfxyu.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%2F1491okxsbh238qypfxyu.png" alt="In 8.2.0 the failed future stays in the set; a retry loads the font, and pendingFonts() still throws the first error" width="800" height="1210"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;With &lt;code&gt;whenComplete&lt;/code&gt; the set is empty after the retry.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;In 8.2.0, loading the font later did not clear the failure.&lt;/strong&gt;&lt;/p&gt;

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

&lt;p&gt;Every run here is &lt;code&gt;flutter test&lt;/code&gt; on the Dart VM. The HTTP 400 in the first test comes from the test binding, not from a real outage.&lt;/p&gt;

&lt;p&gt;What the uncaught error does in a running app was not measured: the zone handler, &lt;code&gt;PlatformDispatcher.onError&lt;/code&gt; and crash reporters are outside these runs. The web was not measured either.&lt;/p&gt;

&lt;p&gt;No workaround for 8.2.0 was measured.&lt;/p&gt;

&lt;h2&gt;
  
  
  Use 8.2.1
&lt;/h2&gt;

&lt;p&gt;The pull request merged on July 31, 2026, and google_fonts 8.2.1 was published 17 minutes later. Its &lt;a href="https://pub.dev/packages/google_fonts/changelog" rel="noopener noreferrer"&gt;changelog&lt;/a&gt; entry reads "Fixes &lt;code&gt;pendingFonts()&lt;/code&gt; retaining failed font loads."&lt;/p&gt;

&lt;p&gt;On September 24 it is still the latest release.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check it yourself
&lt;/h2&gt;

&lt;p&gt;In an empty directory, create this &lt;code&gt;pubspec.yaml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app&lt;/span&gt;
&lt;span class="na"&gt;publish_to&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;none&lt;/span&gt;
&lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;sdk&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;^3.10.0&lt;/span&gt;
&lt;span class="na"&gt;dependencies&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;flutter&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;sdk&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;flutter&lt;/span&gt;
  &lt;span class="na"&gt;google_fonts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;8.2.0&lt;/span&gt;
&lt;span class="na"&gt;dev_dependencies&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;flutter_test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;sdk&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;flutter&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Create a &lt;code&gt;test&lt;/code&gt; directory (&lt;code&gt;mkdir -p test&lt;/code&gt;) and save this as &lt;code&gt;test/pending_fonts_test.dart&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Reproduction for flutter/packages#12240: pendingFonts() after one failed font load.&lt;/span&gt;
&lt;span class="c1"&gt;// TestWidgetsFlutterBinding answers the default HTTP client with status 400, and the font fetch fails on its own.&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="s"&gt;'package:flutter_test/flutter_test.dart'&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="s"&gt;'package:google_fonts/google_fonts.dart'&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;TestWidgetsFlutterBinding&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ensureInitialized&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="n"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'pendingFonts after a failed load'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="kd"&gt;async&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;GoogleFonts&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;config&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;allowRuntimeFetching&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;GoogleFonts&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;lato&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;call&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;'first'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;'second'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;'third'&lt;/span&gt;&lt;span class="p"&gt;])&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="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;GoogleFonts&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;pendingFonts&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="n"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'&lt;/span&gt;&lt;span class="si"&gt;$call&lt;/span&gt;&lt;span class="s"&gt; pendingFonts(): completed'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'&lt;/span&gt;&lt;span class="si"&gt;$call&lt;/span&gt;&lt;span class="s"&gt; pendingFonts(): threw &lt;/span&gt;&lt;span class="si"&gt;${e.runtimeType}&lt;/span&gt;&lt;span class="s"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&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;Then run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;flutter pub get
flutter &lt;span class="nb"&gt;test test&lt;/span&gt;/pending_fonts_test.dart
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Change the dependency to &lt;code&gt;google_fonts: 8.2.1&lt;/code&gt;, run &lt;code&gt;flutter pub get&lt;/code&gt; again and rerun the test. The recorded runs used Flutter 3.41.2 and Dart 3.11.0.&lt;/p&gt;

</description>
      <category>flutter</category>
      <category>dart</category>
      <category>testing</category>
      <category>debugging</category>
    </item>
    <item>
      <title>Run it, bound it, check it every week: how I made a Dart MCP client checkable</title>
      <dc:creator>Yusuf İhsan Görgel</dc:creator>
      <pubDate>Wed, 23 Sep 2026 09:02:58 +0000</pubDate>
      <link>https://dev.to/yusufihsangorgel/making-an-mcp-client-library-checkable-a-runnable-example-bounded-pagination-and-a-weekly-i49</link>
      <guid>https://dev.to/yusufihsangorgel/making-an-mcp-client-library-checkable-a-runnable-example-bounded-pagination-and-a-weekly-i49</guid>
      <description>&lt;p&gt;A protocol library has to connect, list results, stop and follow the spec.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You should be able to check those promises without taking my word for it.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Over one week of pull requests to &lt;a href="https://pub.dev/packages/dart_mcp" rel="noopener noreferrer"&gt;&lt;code&gt;package:dart_mcp&lt;/code&gt;&lt;/a&gt;, I made three of them checkable:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Run it:&lt;/strong&gt; a client example that talks to the existing server example.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bound it:&lt;/strong&gt; list helpers that walk every page and stop after 64.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check it:&lt;/strong&gt; a weekly run against the protocol's own conformance suite.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Start with the client example.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Run both sides in two terminals
&lt;/h2&gt;

&lt;p&gt;The repository had a server example, &lt;code&gt;example/streamable_http_server.dart&lt;/code&gt;. It had no client to pair with it.&lt;/p&gt;

&lt;p&gt;Its readme suggested a &lt;code&gt;curl&lt;/code&gt; command. That shows the wire format. It does not show how the Dart client API fits together.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/dart-lang/ai/pull/671" rel="noopener noreferrer"&gt;PR #671&lt;/a&gt; added &lt;a href="https://github.com/dart-lang/ai/blob/main/pkgs/dart_mcp/example/streamable_http_client.dart" rel="noopener noreferrer"&gt;&lt;code&gt;example/streamable_http_client.dart&lt;/code&gt;&lt;/a&gt;. Start the server, copy the URL it prints, and pass it to the client:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd &lt;/span&gt;pkgs/dart_mcp
dart run example/streamable_http_server.dart
&lt;span class="c"&gt;# Listening on http://127.0.0.1:61009/mcp   (the port changes per run)&lt;/span&gt;

dart run example/streamable_http_client.dart http://127.0.0.1:61009/mcp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the client's output from a run on the current &lt;code&gt;main&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;connecting to server at http://127.0.0.1:61009/mcp
discovering server
Listing tools from server
Found Tool: greet
Calling `greet` tool
Progress: 1/1: Greeting world
Tool call succeeded: [{text: Hello, world!, type: text}]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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%2F66i6ghu87bqvjonur3qm.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%2F66i6ghu87bqvjonur3qm.png" alt="The client discovers the server, lists tools, calls greet, receives a progress notification and the result" width="800" height="989"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The client is 94 lines.&lt;/strong&gt; You can read it in one sitting and copy it into a project as a starting point.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. List everything, but stop at 64 pages
&lt;/h2&gt;

&lt;p&gt;MCP list requests page their results. A server returns items and a &lt;code&gt;nextCursor&lt;/code&gt;. The client sends the cursor back to get the next page.&lt;/p&gt;

&lt;p&gt;Before &lt;a href="https://github.com/dart-lang/ai/pull/682" rel="noopener noreferrer"&gt;PR #682&lt;/a&gt;, the package left that loop to the caller.&lt;/p&gt;

&lt;p&gt;Now there are four helpers: &lt;code&gt;listAllTools&lt;/code&gt;, &lt;code&gt;listAllResources&lt;/code&gt;, &lt;code&gt;listAllResourceTemplates&lt;/code&gt; and &lt;code&gt;listAllPrompts&lt;/code&gt;. Each one returns a &lt;code&gt;Stream&lt;/code&gt; and yields items as pages arrive.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Walks every page. Throws a StateError if page 64 still has a cursor.&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;tool&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;server&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;listAllTools&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tool&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Trust the server? Lift the bound explicitly.&lt;/span&gt;
&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;everything&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;server&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;listAllTools&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;maxPageCount:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;toList&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;A server that never stops paging should not trap your walk.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A server that hands out a cursor on every page keeps a naive &lt;code&gt;while (cursor != null)&lt;/code&gt; loop running forever. By default the helpers stop. When a 64th page still ends in a cursor, they throw a &lt;code&gt;StateError&lt;/code&gt;.&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%2Fc6t64918u2grrc7ixoro.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%2Fc6t64918u2grrc7ixoro.png" alt="The walk requests a page, yields its items, ends on a null cursor, and throws after 64 pages" width="800" height="1046"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The pull request only adds code to &lt;code&gt;client.dart&lt;/code&gt;: 120 lines added, none removed. The single-page methods are untouched. Existing callers keep their exact behaviour.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://github.com/dart-lang/ai/blob/main/pkgs/dart_mcp/test/client/pagination_test.dart" rel="noopener noreferrer"&gt;tests&lt;/a&gt; pin the edges a real server can hit:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;an empty string &lt;code&gt;nextCursor&lt;/code&gt; is a cursor, not the end;&lt;/li&gt;
&lt;li&gt;an empty page in the middle does not end the walk;&lt;/li&gt;
&lt;li&gt;the first page yields before the second page is requested;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;maxPageCount&lt;/code&gt; stops a server that alternates two cursors.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  3. Sampling content as a list, and the error inside a 415
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Sampling content can be one block or a list.&lt;/strong&gt; In the schema, &lt;code&gt;SamplingMessage.content&lt;/code&gt; and &lt;code&gt;CreateMessageResult.content&lt;/code&gt; take one block or an array of blocks. The old getter cast straight to one block, and a list on the wire threw.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/dart-lang/ai/pull/685" rel="noopener noreferrer"&gt;PR #685&lt;/a&gt; changed the getter's type:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="gd"&gt;-  SamplingMessageContentBlock get content =&amp;gt;
-      _value[Keys.content] as SamplingMessageContentBlock;
&lt;/span&gt;&lt;span class="gi"&gt;+  List&amp;lt;SamplingMessageContentBlock&amp;gt; get content {
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One block now reads as a one-element list. On the way out, a single block still goes on the wire as that block.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A 415 now carries the right error code.&lt;/strong&gt; &lt;a href="https://github.com/dart-lang/ai/pull/684" rel="noopener noreferrer"&gt;PR #684&lt;/a&gt; did not change the HTTP status. It changed the JSON-RPC error inside the response:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;       HttpStatus.unsupportedMediaType,
       RpcException(
&lt;span class="gd"&gt;-        McpErrorCodes.headerMismatch,
&lt;/span&gt;&lt;span class="gi"&gt;+        error_code.INVALID_REQUEST,
&lt;/span&gt;         'The request body must be sent as ${ContentType.json.mimeType}',
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The schema binds &lt;code&gt;HeaderMismatch&lt;/code&gt; to &lt;code&gt;400 Bad Request&lt;/code&gt;. &lt;code&gt;Content-Type&lt;/code&gt; is not one of the headers the specification requires. An oversized body already got a generic invalid request error with its own 413, and this branch now matches it.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Run conformance weekly and catch stale expected failures
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/dart-lang/ai/pull/675" rel="noopener noreferrer"&gt;PR #675&lt;/a&gt; added a &lt;a href="https://github.com/dart-lang/ai/blob/main/.github/workflows/conformance.yaml" rel="noopener noreferrer"&gt;workflow&lt;/a&gt; that runs the MCP conformance suite against both fixtures under &lt;code&gt;tool/&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;pull_request&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;paths&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;pkgs/dart_mcp/**'&lt;/span&gt;
  &lt;span class="na"&gt;schedule&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;cron&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;0&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;0&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;0'&lt;/span&gt; &lt;span class="c1"&gt;# weekly&lt;/span&gt;
  &lt;span class="na"&gt;workflow_dispatch&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;conformance&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;continue-on-error&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The suite is still an alpha npm package, pinned to &lt;code&gt;0.2.0-alpha.11&lt;/code&gt;. With &lt;code&gt;continue-on-error&lt;/code&gt;, the job still reports a failure, but the workflow run stays green.&lt;/p&gt;

&lt;p&gt;According to the pull request, every scored 2026-07-28 scenario passed on the server run, and the &lt;code&gt;tasks&lt;/code&gt; extension was the only failure.&lt;/p&gt;

&lt;p&gt;The client run reads a &lt;a href="https://github.com/dart-lang/ai/blob/main/pkgs/dart_mcp/tool/conformance_expected_failures.yaml" rel="noopener noreferrer"&gt;baseline of expected failures&lt;/a&gt;. The package has no OAuth client yet. The file lists the auth scenarios. &lt;a href="https://github.com/dart-lang/ai/pull/681" rel="noopener noreferrer"&gt;PR #681&lt;/a&gt; later gave the client step a timeout.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The baseline is checked in both directions.&lt;/strong&gt;&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%2F42lhoo4cjtreazemqkha.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%2F42lhoo4cjtreazemqkha.png" alt="A failing listed scenario is fine; a failing unlisted one fails the job; a passing listed one also fails the job" width="799" height="226"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A listed scenario that starts passing also fails the job. When OAuth support lands, the list has to shrink with it. A stale entry shows up as a failing job.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Docs that match the code
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/dart-lang/ai/pull/673" rel="noopener noreferrer"&gt;PR #673&lt;/a&gt; added a &lt;a href="https://github.com/dart-lang/ai/blob/main/pkgs/dart_mcp/DEVELOPING.md" rel="noopener noreferrer"&gt;&lt;code&gt;DEVELOPING.md&lt;/code&gt;&lt;/a&gt; for the package. It covers the schema files, the checks CI runs, the conformance fixtures and the changelog convention.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/dart-lang/ai/pull/677" rel="noopener noreferrer"&gt;PR #677&lt;/a&gt; cleaned up the README after elicitation, sampling and roots came out of the server. A section still described a call pattern that no longer existed. It now describes answering with an &lt;code&gt;InputRequiredResult&lt;/code&gt;, and Streamable HTTP is marked supported in the support table.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would do the same way again
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Add helpers beside old methods, not over them.&lt;/strong&gt; No existing call to a single-page list method had to change.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make the safe behaviour the default.&lt;/strong&gt; A 64-page bound that throws, with &lt;code&gt;null&lt;/code&gt; as the explicit way out.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Let an alpha dependency report, not gate.&lt;/strong&gt; Its failures stay visible without turning the run red.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Run the example, read the pagination tests, and open the conformance workflow.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Correction, 2026-09-23: an earlier version said #684 fixed a status code. It changed the JSON-RPC error code sent with 415, and the status stayed 415.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>dart</category>
      <category>mcp</category>
      <category>opensource</category>
      <category>testing</category>
    </item>
    <item>
      <title>Your leak test is probably measuring the test runner</title>
      <dc:creator>Yusuf İhsan Görgel</dc:creator>
      <pubDate>Mon, 03 Aug 2026 08:48:55 +0000</pubDate>
      <link>https://dev.to/yusufihsangorgel/your-leak-test-is-probably-measuring-the-test-runner-1m3m</link>
      <guid>https://dev.to/yusufihsangorgel/your-leak-test-is-probably-measuring-the-test-runner-1m3m</guid>
      <description>&lt;p&gt;I have a test that checks a native buffer gets freed. Yesterday I ran it two&lt;br&gt;
ways, twenty seconds apart, on the same machine against the same code.&lt;/p&gt;

&lt;p&gt;Alone, it measured 5.11 MB of growth.&lt;/p&gt;

&lt;p&gt;As part of the full suite, it measured 489.13 MB.&lt;/p&gt;

&lt;p&gt;Nothing about the code under test changed. The only difference was what else&lt;br&gt;
was running at the same time.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why this happens
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;ProcessInfo.currentRss&lt;/code&gt; returns the resident set size of the &lt;strong&gt;process&lt;/strong&gt;. For&lt;br&gt;
a package that allocates through FFI that is exactly what you want, because&lt;br&gt;
native allocations never show up in Dart heap statistics. It is also the&lt;br&gt;
problem: the number includes everything else the process is doing.&lt;/p&gt;

&lt;p&gt;And &lt;code&gt;dart test&lt;/code&gt; runs your suite files concurrently, in one process, by default.&lt;/p&gt;

&lt;p&gt;So a leak test that reads &lt;code&gt;currentRss&lt;/code&gt; is reading a counter that another test&lt;br&gt;
file is also writing to. When my JSON parsing suite ran next to it, the number&lt;br&gt;
moved by nearly half a gigabyte. The bound that decides whether that test passes&lt;br&gt;
is 16.4 MB. The noise is thirty times the entire decision.&lt;/p&gt;

&lt;p&gt;The failure mode is nastier than a wrong number. The test passes when you run&lt;br&gt;
it by itself, which is what you do while writing it, and fails in CI where the&lt;br&gt;
whole suite runs. That reads like flakiness. You retry the job, it fails again,&lt;br&gt;
you widen the threshold until it stops complaining, and now the test cannot&lt;br&gt;
detect anything.&lt;/p&gt;
&lt;h2&gt;
  
  
  Move the measurement into a child process
&lt;/h2&gt;

&lt;p&gt;Move the allocation loop into its own script and run it with&lt;br&gt;
&lt;code&gt;Process.runSync&lt;/code&gt;. The child sees only its own work.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="c1"&gt;// test/leak_probe.dart&lt;/span&gt;
&lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;mode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;args&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="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;iterations&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;parse&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="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

  &lt;span class="c1"&gt;// ... build `cycle` for the mode under test ...&lt;/span&gt;

  &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;warmup&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;i&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="n"&gt;cycle&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;before&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ProcessInfo&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;currentRss&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;iterations&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;i&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="n"&gt;cycle&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;grown&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ProcessInfo&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;currentRss&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;before&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1048576&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="n"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'RSS_DELTA_MB=&lt;/span&gt;&lt;span class="si"&gt;${grown.toStringAsFixed(2)}&lt;/span&gt;&lt;span class="s"&gt;'&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;And from the test:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kd"&gt;final&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;Process&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;runSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Platform&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;resolvedExecutable&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="s"&gt;'run'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="s"&gt;'test/leak_probe.dart'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="s"&gt;'&lt;/span&gt;&lt;span class="si"&gt;$iterations&lt;/span&gt;&lt;span class="s"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;]);&lt;/span&gt;

&lt;span class="n"&gt;expect&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="na"&gt;exitCode&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="nl"&gt;reason:&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;stderr&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;reported&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="n"&gt;RegExp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sx"&gt;r'RSS_DELTA_MB=(-?[0-9.]+)'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;firstMatch&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="na"&gt;stdout&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reported&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;isNotNull&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nl"&gt;reason:&lt;/span&gt; &lt;span class="s"&gt;'leak_probe printed no measurement: &lt;/span&gt;&lt;span class="si"&gt;${result.stdout}&lt;/span&gt;&lt;span class="s"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;grownMb&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;double&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reported&lt;/span&gt;&lt;span class="o"&gt;!.&lt;/span&gt;&lt;span class="na"&gt;group&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="o"&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 exit-code check runs before the parent parses the marker. A child that failed is reported with its own stderr instead of being read as a result.&lt;/p&gt;

&lt;p&gt;Two details in there are not decoration.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The warm-up loop.&lt;/strong&gt; The first iteration pays for library loading, runtime&lt;br&gt;
dispatch and the allocator's initial arena. Without it you attribute all of&lt;br&gt;
that startup to whichever thing you measured first. I once benchmarked two APIs&lt;br&gt;
and got 231 ms versus 2 ms, then added warm-up and got 1.3 ms versus 2.5 ms.&lt;br&gt;
The ordering alone produced a hundredfold error.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The marker.&lt;/strong&gt; The child prints &lt;code&gt;RSS_DELTA_MB=&lt;/code&gt; and the test greps for it&lt;br&gt;
rather than parsing the last line. &lt;code&gt;dart run&lt;/code&gt; writes build-hook progress to&lt;br&gt;
stdout. The output is not just your number. Parsing &lt;code&gt;stdout.trim()&lt;/code&gt; works&lt;br&gt;
until the day a hook logs something, and then it breaks in a way that looks&lt;br&gt;
like your code changed.&lt;/p&gt;

&lt;p&gt;Here is the same measurement taken all three ways, this morning:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;How it ran&lt;/th&gt;
&lt;th&gt;RSS delta&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;in-process, alone&lt;/td&gt;
&lt;td&gt;5.11 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;in-process, full suite&lt;/td&gt;
&lt;td&gt;489.13 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;child process&lt;/td&gt;
&lt;td&gt;3.25 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;h2&gt;
  
  
  Derive the threshold, do not pick it
&lt;/h2&gt;

&lt;p&gt;A separate process gives you a clean number. It does not tell you what to&lt;br&gt;
compare it against, and this is where I had been guessing.&lt;/p&gt;

&lt;p&gt;My original bound was 50 MB. I picked it because it was round and comfortably&lt;br&gt;
above what I saw when the test passed. The payload it was parsing is 4 KB.&lt;/p&gt;

&lt;p&gt;A threshold chosen that way is not an assertion. It is a wish with a number&lt;br&gt;
next to it, and it will accept anything that fits under the wish.&lt;/p&gt;

&lt;p&gt;Derive it from the payload instead. Ask what the smallest leak on this code&lt;br&gt;
path would actually cost over the iteration count, then bound well under that:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;probe&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'parseBytes'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="s"&gt;'padded input and the tape behind at()'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;      &lt;span class="mf"&gt;65.5&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'decodeBytes'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;'the tape and the native copy of the input'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="mf"&gt;65.5&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'ndjson'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;      &lt;span class="s"&gt;'the tape and the native copy of the input'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="mf"&gt;92.1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'pointer'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;     &lt;span class="s"&gt;'the native copy of a long JSON pointer'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;     &lt;span class="mf"&gt;46.9&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;what&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;smallestLeakMb&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;probe&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="n"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'&lt;/span&gt;&lt;span class="si"&gt;$mode&lt;/span&gt;&lt;span class="s"&gt; releases &lt;/span&gt;&lt;span class="si"&gt;$what&lt;/span&gt;&lt;span class="s"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// ... run the probe, parse grownMb ...&lt;/span&gt;
    &lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;grownMb&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lessThan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;smallestLeakMb&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="nl"&gt;reason:&lt;/span&gt; &lt;span class="s"&gt;'grew &lt;/span&gt;&lt;span class="si"&gt;${grownMb}&lt;/span&gt;&lt;span class="s"&gt;MB over &lt;/span&gt;&lt;span class="si"&gt;$iterations&lt;/span&gt;&lt;span class="s"&gt; iterations; losing the '&lt;/span&gt;
            &lt;span class="s"&gt;'smallest native buffer on this path would cost &lt;/span&gt;&lt;span class="si"&gt;${smallestLeakMb}&lt;/span&gt;&lt;span class="s"&gt;MB'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nl"&gt;testOn:&lt;/span&gt; &lt;span class="s"&gt;'!windows'&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;Each number is the cost of dropping the single cheapest &lt;code&gt;free&lt;/code&gt; on that path.&lt;br&gt;
The bound sits at a quarter of it. That leaves room for allocator behaviour&lt;br&gt;
while staying far below any real leak, and it means all four paths have four&lt;br&gt;
different bounds, because they allocate different things.&lt;/p&gt;

&lt;p&gt;One more thing about the source data: use bytes that do not compress. I had a&lt;br&gt;
test feeding a flat grey PNG through an image pipeline, and it compressed so&lt;br&gt;
well that the buffers I was trying to observe never got big enough to see.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verify the test can fail
&lt;/h2&gt;

&lt;p&gt;All of the above is still just a story about a number until you check the one&lt;br&gt;
thing that matters: delete a &lt;code&gt;free&lt;/code&gt; and confirm the test turns red.&lt;/p&gt;

&lt;p&gt;Do it per-call, not all at once. When I first ran this audit across my&lt;br&gt;
packages, the leak test in one of them stayed green with &lt;strong&gt;all four&lt;/strong&gt; native&lt;br&gt;
frees removed, because the total still landed under the guessed threshold. Any&lt;br&gt;
single one of them, deleted on its own, has to fail. If it does not, that call&lt;br&gt;
site is unprotected regardless of what the suite says.&lt;/p&gt;

&lt;p&gt;While doing exactly this, my mutation script reported everything green on the&lt;br&gt;
first run. The script was fine. The Python &lt;code&gt;assert&lt;/code&gt; that applied each mutation&lt;br&gt;
was failing silently. Nothing was ever mutated. A check passing does not&lt;br&gt;
mean it ran.&lt;/p&gt;

&lt;h2&gt;
  
  
  What RSS cannot answer
&lt;/h2&gt;

&lt;p&gt;I deleted a test during this work and did not replace it.&lt;/p&gt;

&lt;p&gt;It asserted that a &lt;code&gt;NativeFinalizer&lt;/code&gt; reclaimed a document nobody closed, by&lt;br&gt;
measuring resident set after a fixed amount of work. That is a bet on when the&lt;br&gt;
garbage collector runs. It passed locally and failed on Linux CI. A rewrite&lt;br&gt;
comparing dropped references against retained ones was no better: measured back&lt;br&gt;
to back, the arms came out 123 MB and 177 MB. The margin it claimed was&lt;br&gt;
not there.&lt;/p&gt;

&lt;p&gt;RSS does not fall when native memory is freed, because the allocator keeps the&lt;br&gt;
pages. It cannot answer "was this reclaimed," and the collector makes no timing&lt;br&gt;
promise a test could hold it to.&lt;/p&gt;

&lt;p&gt;Deleting that test made the suite honest. What replaced it is coverage of the&lt;br&gt;
things that are actually deterministic: using a closed handle throws, closing&lt;br&gt;
twice is safe, malformed input raises rather than corrupting memory.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Measurement runs in a child process&lt;/li&gt;
&lt;li&gt;The child warms up before the timed loop&lt;/li&gt;
&lt;li&gt;Output uses a marker the parent greps for, not the last line&lt;/li&gt;
&lt;li&gt;Each allocation site is measured in its own loop&lt;/li&gt;
&lt;li&gt;The threshold is derived from the payload, not picked&lt;/li&gt;
&lt;li&gt;Source data does not compress&lt;/li&gt;
&lt;li&gt;Deleting &lt;strong&gt;any single&lt;/strong&gt; &lt;code&gt;free&lt;/code&gt; turns the test red&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The last one is the cheapest and the only one that proves the rest.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Numbers here were measured on Dart 3.11.0, macOS 26.6, Apple M4, against&lt;br&gt;
&lt;code&gt;simdjson_dart&lt;/code&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>dart</category>
      <category>testing</category>
      <category>ffi</category>
      <category>flutter</category>
    </item>
    <item>
      <title>The Biggest MCP Update Ever, Explained With a Restaurant</title>
      <dc:creator>Yusuf İhsan Görgel</dc:creator>
      <pubDate>Wed, 29 Jul 2026 06:43:25 +0000</pubDate>
      <link>https://dev.to/yusufihsangorgel/the-biggest-mcp-update-ever-explained-with-a-restaurant-4ecp</link>
      <guid>https://dev.to/yusufihsangorgel/the-biggest-mcp-update-ever-explained-with-a-restaurant-4ecp</guid>
      <description>&lt;p&gt;&lt;em&gt;MCP 2026-07-28 is out, and sessions are gone. The whole thing in plain English.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsrnxgt336i8tws5wz8hf.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsrnxgt336i8tws5wz8hf.gif" alt="Before: a sticky load balancer pins every request to one instance. After: any instance takes the next request." width="759" height="269"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Before: the balancer is sticky, so every request goes to Instance A and dies with it. After: the next free instance takes it.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;There is a restaurant in your city with one very good waiter. He remembers everything. Your table number, your no-onions rule, the fact that your friend wants the bill split in two. You never repeat yourself.&lt;/p&gt;

&lt;p&gt;Then the waiter gets sick.&lt;/p&gt;

&lt;p&gt;The new waiter knows nothing. Not your order, not the onions, not the split bill. You start again from zero. The whole restaurant, it turns out, was running on one person's memory.&lt;/p&gt;

&lt;p&gt;Until this week, the Model Context Protocol worked exactly like that restaurant.&lt;/p&gt;
&lt;h2&gt;
  
  
  First: what is MCP?
&lt;/h2&gt;

&lt;p&gt;If you have never heard of MCP, the docs' own one-line pitch is the fastest start: &lt;strong&gt;&lt;a href="https://modelcontextprotocol.io/" rel="noopener noreferrer"&gt;a USB-C port for AI applications.&lt;/a&gt;&lt;/strong&gt; It is an open standard. It lets AI applications (IDEs, chat assistants, agents) plug into tools and data the same way, no matter who built them.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Funjdrllhbeul51hljbna.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Funjdrllhbeul51hljbna.gif" alt="Why MCP exists: one integration per pair, or one per side" width="800" height="333"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Without a shared protocol you build one integration per pair. With one, you build one per side.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;In our restaurant: your AI app is the &lt;strong&gt;host&lt;/strong&gt;, and it sends one &lt;strong&gt;client&lt;/strong&gt; to each kitchen it wants to order from. You, at the counter, are that client. The kitchen is the &lt;strong&gt;server&lt;/strong&gt;, a program that offers tools, resources (files and data), and prompts. The dishes on the menu are the &lt;strong&gt;tools&lt;/strong&gt;. And MCP itself is the ordering system: how you ask, how the kitchen answers, what happens when something goes wrong.&lt;/p&gt;

&lt;p&gt;On July 28, the MCP team released the &lt;strong&gt;&lt;a href="https://modelcontextprotocol.io/specification/2026-07-28" rel="noopener noreferrer"&gt;2026-07-28 revision&lt;/a&gt;&lt;/strong&gt; (their word for a version). When the release candidate went out in May they called it the largest revision of the protocol since launch. Having now read the whole diff, I would not argue: it changes the ordering system from top to bottom.&lt;/p&gt;
&lt;h2&gt;
  
  
  The waiter is gone: MCP is now stateless
&lt;/h2&gt;

&lt;p&gt;In the old protocol, a client and a server first did a handshake called &lt;code&gt;initialize&lt;/code&gt;, a "hello, who are you?" step. After that, the server kept a &lt;strong&gt;session&lt;/strong&gt; for you. On HTTP there was even a special header, &lt;code&gt;Mcp-Session-Id&lt;/code&gt;, that locked you to one specific server instance (one running copy of the server).&lt;/p&gt;

&lt;p&gt;That session was the waiter's memory, and it had the waiter's weakness. If the instance holding it crashed, your session died with it. So if you ran three copies of the server, the load balancer in front of them had to be sticky: it had to send you back to the same copy every time, the way a host sends you back to the same waiter. Serverless platforms broke this outright. Services like AWS Lambda start and stop instances whenever a request arrives, so there is no same copy to go back to.&lt;/p&gt;

&lt;p&gt;The new version removes sessions from the protocol completely. &lt;code&gt;initialize&lt;/code&gt; is gone. Instead, &lt;strong&gt;every request carries everything the server needs&lt;/strong&gt;, inside a small &lt;code&gt;_meta&lt;/code&gt; block: which protocol version you speak and what your client can do. Just the technical facts, not your conversation; anything else the server needs arrives as normal request parameters.&lt;/p&gt;

&lt;p&gt;So the restaurant reopened as a counter-service place: you order at the counter, receipt in hand. Your whole order is printed on the receipt, and any cashier can pick it up and serve it. If one cashier goes home, the next one reads the same receipt and continues. Nobody has to remember you.&lt;/p&gt;

&lt;p&gt;Stateless does not mean your server has to forget everything. It means the protocol no longer remembers for you. If a server needs state across calls, it mints its own handle and returns it from a tool like any other output. The model passes that handle back as an ordinary argument on the next call, so the state is visible in the request instead of hidden in the transport. It is the ticket number on your receipt: the counter does not know who you are, but it knows what number 47 ordered.&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%2Fmsdvzhcu0l591uv3yr56.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%2Fmsdvzhcu0l591uv3yr56.png" alt="Old: the waiter remembers. New: the receipt carries it." width="800" height="420"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Old: one waiter's memory. New: a receipt any cashier can read.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The smallest possible before and after:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Before&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2025&lt;/span&gt;&lt;span class="err"&gt;-era):&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;a&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;handshake&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;first,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;then&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;a&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;session&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;to&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;protect&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"jsonrpc"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"method"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"initialize"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"params"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"protocolVersion"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2025-06-18"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"capabilities"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"clientInfo"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"my-app"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1.0.0"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;After&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2026-07-28&lt;/span&gt;&lt;span class="err"&gt;):&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;no&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;handshake,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;the&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;receipt&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;carries&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;everything&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"jsonrpc"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"method"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"tools/list"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"params"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"_meta"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"io.modelcontextprotocol/protocolVersion"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-07-28"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"io.modelcontextprotocol/clientCapabilities"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The animation at the top of this article is the same story in fifteen seconds. On the left the host is sticky: every request goes to Instance A, with a session tag pinned to it, and when A dies the client dies with it. On the right the host sends each new arrival to whichever counter is free, taking turns between A, B and C. When A dies, the queue keeps moving.&lt;/p&gt;

&lt;p&gt;This is the payoff. You can now deploy an MCP server on serverless or edge platforms (small servers close to the user), or scale it behind any ordinary load balancer.&lt;/p&gt;

&lt;h2&gt;
  
  
  The card at the door: &lt;code&gt;server/discover&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Without a handshake, how do you learn what a server offers? One new call: &lt;code&gt;server/discover&lt;/code&gt;. It returns the server's capabilities (what it can do) and the protocol versions it supports, in a single response, and every 2026-07-28 server &lt;strong&gt;must&lt;/strong&gt; implement it. Clients do not have to call it: you can send &lt;code&gt;tools/list&lt;/code&gt; straight away and handle an unsupported-version error if one comes back. &lt;code&gt;server/discover&lt;/code&gt; is there for when you want the whole picture up front, and it is how you probe an older server over stdio.&lt;/p&gt;

&lt;p&gt;There is now a card at the door listing what this branch can do. You still ask for the dishes separately, with &lt;code&gt;tools/list&lt;/code&gt;. The card even says how long it stays fresh.&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%2F4c88pdovgxbnd9pgoxwv.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%2F4c88pdovgxbnd9pgoxwv.png" alt="Two questions, two different calls" width="800" height="420"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Capabilities at the door, dishes on the menu. Two different calls.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The buzzer: &lt;code&gt;subscriptions/listen&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Sometimes the kitchen needs to tell &lt;em&gt;you&lt;/em&gt; something: a resource changed, the tool list was updated. The old protocol did this with a stream over HTTP GET (a stream is a connection that stays open so the server can keep sending; over HTTP these are SSE, Server-Sent Events), plus separate &lt;code&gt;resources/subscribe&lt;/code&gt; and &lt;code&gt;resources/unsubscribe&lt;/code&gt; calls. All of that is replaced by one method: &lt;code&gt;subscriptions/listen&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Think of the buzzer they hand you at the counter: it sits on your table and rings only when your order changes. You tell the server what you care about (a resource changing, or the tool or prompt list changing), and the server opens a stream tagged with your subscription ID. When the server wants to close the stream on purpose, it should now say a proper goodbye: a small &lt;code&gt;"complete"&lt;/code&gt; result carrying that same ID. That way your client knows the difference between a normal close and a broken connection.&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%2F27rkfcndj5m2scuxxdnr.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%2F27rkfcndj5m2scuxxdnr.png" alt="One buzzer instead of four moving parts" width="800" height="420"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;One buzzer replaces the old GET stream and both subscribe calls.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Something else quietly disappeared: you can no longer resume a broken stream. The old spec let you pick up where you left off with a &lt;code&gt;Last-Event-ID&lt;/code&gt; header, which meant the server had to remember what it had already sent you. State again, hiding in the transport. Now, if a stream breaks, you send the request again with a new ID. If your buzzer breaks, nobody repairs it mid-ring; you take a new buzzer from the counter.&lt;/p&gt;

&lt;h2&gt;
  
  
  The question written on your receipt: MRTR
&lt;/h2&gt;

&lt;p&gt;Nobody can come to your table anymore; the waiter who would have carried the kitchen's question is gone. So the cashier writes on your receipt: "Which sauce?" You come back to the counter with the same receipt and the answer written on it. Any cashier can complete the order from there. That is the pattern the spec settled on, and it has a name: &lt;strong&gt;Multi Round-Trip Requests (MRTR)&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;In the old protocol, a server could send requests &lt;em&gt;back&lt;/em&gt; to the client in the middle of an operation. For example: "please ask the user a question" or "please run this through your model". That needs a two-way, stateful channel (stateful means the server must remember you between messages), which is exactly the thing the new version threw out.&lt;/p&gt;

&lt;p&gt;The replacement: when a server needs something from you, it does not call you. It finishes the current request with a result marked &lt;code&gt;resultType: "input_required"&lt;/code&gt;, and lists what it needs in &lt;code&gt;inputRequests&lt;/code&gt;. Your client then &lt;strong&gt;retries the same request&lt;/strong&gt; with the answers attached. If the server sent a state token, the client sends that back too, unread; that is the note scribbled on your receipt. The server reads it and picks up where it stopped.&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%2Fk3cwwm2k9x4h11qck317.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%2Fk3cwwm2k9x4h11qck317.png" alt="The kitchen writes the question on your receipt" width="800" height="420"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The kitchen writes its question on your receipt. You come back with the answer.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;This is also why every result must now declare a &lt;code&gt;resultType&lt;/code&gt;, either &lt;code&gt;"complete"&lt;/code&gt; or &lt;code&gt;"input_required"&lt;/code&gt;. If an older server sends nothing, clients treat it as &lt;code&gt;"complete"&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The menu photo has an expiry time: caching hints
&lt;/h2&gt;

&lt;p&gt;There is a photo of the menu taped up, with a note on it: valid for 10 minutes. That is what the new caching hints are. Being stateless has a cost, because clients ask "what tools do you have?" far more often, so the spec standardises the answer's shelf life.&lt;/p&gt;

&lt;p&gt;Five results (&lt;code&gt;tools/list&lt;/code&gt;, &lt;code&gt;prompts/list&lt;/code&gt;, &lt;code&gt;resources/list&lt;/code&gt;, &lt;code&gt;resources/read&lt;/code&gt;, &lt;code&gt;resources/templates/list&lt;/code&gt;) plus &lt;code&gt;server/discover&lt;/code&gt; now carry two required fields: &lt;code&gt;ttlMs&lt;/code&gt; and &lt;code&gt;cacheScope&lt;/code&gt;. &lt;code&gt;ttlMs&lt;/code&gt; is how long the result stays valid, in milliseconds. &lt;code&gt;cacheScope&lt;/code&gt; decides who else may reuse it: &lt;code&gt;"private"&lt;/code&gt; means only the same logged-in user (the same auth token), &lt;code&gt;"public"&lt;/code&gt; means anyone. The photo either goes on the public board next to the card at the door, or stays on your table where only you can use it. Only final &lt;code&gt;"complete"&lt;/code&gt; results carry these hints; an &lt;code&gt;"input_required"&lt;/code&gt; half-answer can never be saved for reuse.&lt;/p&gt;

&lt;h2&gt;
  
  
  The complaint codes on the wall
&lt;/h2&gt;

&lt;p&gt;Every branch of the restaurant now prints the same complaint codes on the wall. Error codes were renumbered and, for the first time, given a real rule about who owns which numbers: header mismatch is &lt;code&gt;-32020&lt;/code&gt;, missing required client capability is &lt;code&gt;-32021&lt;/code&gt;, unsupported protocol version is &lt;code&gt;-32022&lt;/code&gt;. The range &lt;code&gt;-32020&lt;/code&gt; to &lt;code&gt;-32099&lt;/code&gt; now belongs to the spec itself, and servers and clients must not invent their own codes inside it. One older number moved too: resource not found is now &lt;code&gt;-32602&lt;/code&gt; instead of &lt;code&gt;-32002&lt;/code&gt;, so clients should keep accepting the old code from older servers. Your client can react to a number instead of reading an error message and guessing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The old side door is closing
&lt;/h2&gt;

&lt;p&gt;A release this big also says goodbye to things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Roots&lt;/strong&gt;, &lt;strong&gt;Sampling&lt;/strong&gt; and &lt;strong&gt;Logging&lt;/strong&gt; are deprecated: still working today, marked for removal. Roots let a server see your folders; Sampling let it borrow your model. Logging control moved into &lt;code&gt;_meta&lt;/code&gt;, so you set a &lt;code&gt;logLevel&lt;/code&gt; per request instead of calling &lt;code&gt;logging/setLevel&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;ping&lt;/code&gt;&lt;/strong&gt;, &lt;strong&gt;&lt;code&gt;logging/setLevel&lt;/code&gt;&lt;/strong&gt;, and the roots list-changed notification are removed.&lt;/li&gt;
&lt;li&gt;The original &lt;strong&gt;HTTP+SSE transport&lt;/strong&gt; from 2024 (a transport is the way messages travel) is now formally deprecated under the new lifecycle policy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tasks&lt;/strong&gt; moved out of the core into an extension, so the core spec stays smaller.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OAuth Dynamic Client Registration&lt;/strong&gt; is deprecated and replaced by Client ID Metadata Documents. Skip this bullet if you do not touch OAuth.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The old side door still opens for a while, because the spec defines careful rules for talking to older servers. But the sign on it now says: please use the main entrance.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I am the one telling you this
&lt;/h2&gt;

&lt;p&gt;I am not neutral about this release. For the last few weeks I have been moving the Dart team's MCP SDK (&lt;a href="https://pub.dev/packages/dart_mcp" rel="noopener noreferrer"&gt;&lt;code&gt;package:dart_mcp&lt;/code&gt;&lt;/a&gt;) to it, with five merged pull requests so far in dart-lang/ai. The work is tracked publicly in &lt;a href="https://github.com/dart-lang/ai/issues/162" rel="noopener noreferrer"&gt;dart-lang/ai#162&lt;/a&gt;. When the final spec came out, I compared it line by line with the release candidate (the near-final draft) and posted the report there. So everything above comes from the schema and the docs themselves, not from a summary of a summary.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this means for you
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;If you build apps that use MCP servers:&lt;/strong&gt; wait for your SDK to update. After that there is less to get wrong: no handshake, no session to lose. Version and capabilities travel with every request.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If you write MCP servers:&lt;/strong&gt; tape this one to the kitchen wall. &lt;code&gt;server/discover&lt;/code&gt; is mandatory now, and every result needs a &lt;code&gt;resultType&lt;/code&gt;. Your &lt;code&gt;*/list&lt;/code&gt; and &lt;code&gt;resources/read&lt;/code&gt; results need &lt;code&gt;ttlMs&lt;/code&gt; and &lt;code&gt;cacheScope&lt;/code&gt; on top of that. The painful part is anywhere you used to call back into the client: that has to be redesigned around &lt;code&gt;input_required&lt;/code&gt;, and it changes your control flow, not just your types. What you get back is a server that runs anywhere.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If you are just curious:&lt;/strong&gt; the practical news is freedom in where servers can run. MCP servers used to need a machine that stays up and remembers things. Now they can run as short-lived functions close to users, and scale like any normal web service.&lt;/p&gt;

&lt;h2&gt;
  
  
  Also changed, in one breath
&lt;/h2&gt;

&lt;p&gt;A few smaller items, so you know they exist:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The OpenTelemetry keys &lt;code&gt;traceparent&lt;/code&gt;, &lt;code&gt;tracestate&lt;/code&gt; and &lt;code&gt;baggage&lt;/code&gt; got reserved slots in &lt;code&gt;_meta&lt;/code&gt;, so one request can be traced across services.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;tools/list&lt;/code&gt; should now return tools in a stable order.&lt;/li&gt;
&lt;li&gt;A tool parameter can now be mirrored into an HTTP header with the new &lt;code&gt;x-mcp-header&lt;/code&gt; annotation, under strict naming rules.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;clientInfo&lt;/code&gt; is no longer required on requests, and servers should now send &lt;code&gt;serverInfo&lt;/code&gt; in every result's &lt;code&gt;_meta&lt;/code&gt;. Both are recommended, not mandatory.&lt;/li&gt;
&lt;li&gt;JSON Schema &lt;code&gt;$ref&lt;/code&gt; resolution over the network (fetching schema pieces from other servers) is now banned by default.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of it changes the story above. It is the boring kind of change, which in a protocol is the good kind.&lt;/p&gt;

&lt;h2&gt;
  
  
  Your receipt, please
&lt;/h2&gt;

&lt;p&gt;A restaurant that runs on one waiter's memory is a lovely place, right up until the day he is not there. Counter service is duller. It also never loses your order, and it can open a hundred branches without breaking.&lt;/p&gt;

&lt;p&gt;MCP just made that trade-off, and I think it chose well. If you want to see the change happen in real code, the Dart migration in &lt;a href="https://github.com/dart-lang/ai/issues/162" rel="noopener noreferrer"&gt;dart-lang/ai#162&lt;/a&gt; is open to read, one pull request at a time.&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>ai</category>
      <category>programming</category>
      <category>beginners</category>
    </item>
    <item>
      <title>The object that falls out of the Set it is still in — mutable hashCode in Dart</title>
      <dc:creator>Yusuf İhsan Görgel</dc:creator>
      <pubDate>Fri, 24 Jul 2026 08:39:13 +0000</pubDate>
      <link>https://dev.to/yusufihsangorgel/the-object-that-falls-out-of-the-set-it-is-still-in-mutable-hashcode-in-dart-2665</link>
      <guid>https://dev.to/yusufihsangorgel/the-object-that-falls-out-of-the-set-it-is-still-in-mutable-hashcode-in-dart-2665</guid>
      <description>&lt;p&gt;Here is a &lt;code&gt;Set&lt;/code&gt; with one element. One assignment later, &lt;code&gt;contains&lt;/code&gt; stops finding it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;attrs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;Object&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;{&lt;/span&gt;&lt;span class="s"&gt;'page'&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="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;tag&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Tag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'intro'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;attrs&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;seen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;tag&lt;/span&gt;&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="n"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;seen&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tag&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt; &lt;span class="c1"&gt;// true&lt;/span&gt;
&lt;span class="n"&gt;attrs&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;'page'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="n"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;seen&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tag&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt; &lt;span class="c1"&gt;// false&lt;/span&gt;
&lt;span class="n"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;identical&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tag&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;seen&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;first&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt; &lt;span class="c1"&gt;// true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The object is in the set. &lt;code&gt;identical&lt;/code&gt; confirms it is the same object. &lt;code&gt;seen.contains(tag)&lt;/code&gt; says it isn't there. All I did in between was change a map I had a reference to.&lt;/p&gt;

&lt;p&gt;I shipped this aliasing bug three times across two of my own packages before I caught it, and one of those times involved the map feeding &lt;code&gt;hashCode&lt;/code&gt;, exactly the version above. This is not a contrived puzzle. It is what happens when a value type puts a mutable field into its &lt;code&gt;hashCode&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the set loses it
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;Set&lt;/code&gt; is a hash set. When you add an object, it computes &lt;code&gt;hashCode&lt;/code&gt;, picks a bucket, and drops the object in. When you later ask &lt;code&gt;contains&lt;/code&gt;, it computes &lt;code&gt;hashCode&lt;/code&gt; again, goes to that bucket, and looks. If the hashes differ, it looks in the wrong bucket and finds nothing.&lt;/p&gt;

&lt;p&gt;That's fine as long as &lt;code&gt;hashCode&lt;/code&gt; never changes while the object is in the set. It is the one rule hashing depends on.&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%2Fy5hm2vkiu7c26zmof590.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%2Fy5hm2vkiu7c26zmof590.png" alt="Left: adding the tag files it in bucket 3, the bucket its hash points at. Right: after the caller mutates the metadata map the tag's hash becomes 7, but the tag still sits in bucket 3, while contains() computes the new hash and checks bucket 7, which is empty. The object never moved; its hash did." width="800" height="415"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Here is the &lt;code&gt;Tag&lt;/code&gt; that breaks it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Tag&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;Tag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;attrs&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kt"&gt;Map&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;Object&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;attrs&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// stored by reference&lt;/span&gt;

  &lt;span class="nd"&gt;@override&lt;/span&gt;
  &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="kd"&gt;operator&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Object&lt;/span&gt; &lt;span class="n"&gt;other&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="n"&gt;other&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="n"&gt;Tag&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;other&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;name&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;_mapEquals&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;attrs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;other&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;attrs&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="nd"&gt;@override&lt;/span&gt;
  &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="kd"&gt;get&lt;/span&gt; &lt;span class="n"&gt;hashCode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="kt"&gt;Object&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_mapHash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;attrs&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Element-wise, one level deep: each value compares and hashes with its own&lt;/span&gt;
&lt;span class="c1"&gt;// == and hashCode. package:collection's MapEquality does the same thing.&lt;/span&gt;
&lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="nf"&gt;_mapEquals&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Map&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;Object&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;Map&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;Object&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;length&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;containsKey&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;a&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;b&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="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nf"&gt;_mapHash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Map&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;Object&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="o"&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;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="o"&gt;^=&lt;/span&gt; &lt;span class="kt"&gt;Object&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// xor: order-independent&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;h&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;&lt;code&gt;attrs&lt;/code&gt; is &lt;code&gt;final&lt;/code&gt;. That makes it look immutable. It isn't. &lt;code&gt;final&lt;/code&gt; freezes the &lt;em&gt;reference&lt;/em&gt;, not the map it points at. The constructor stored the exact map the caller passed, and the caller still has it. When they write &lt;code&gt;attrs['page'] = 2&lt;/code&gt;, they are writing into the same map &lt;code&gt;tag.attrs&lt;/code&gt; reads. &lt;code&gt;hashCode&lt;/code&gt; folds &lt;code&gt;attrs&lt;/code&gt; in. When the map changes, &lt;code&gt;tag&lt;/code&gt;'s hash changes, and the bucket the set filed it under no longer matches the bucket &lt;code&gt;contains&lt;/code&gt; computes. The object is sitting in the set, in a bucket the lookup never visits.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;final&lt;/code&gt; is the tell that makes this easy to miss. It reads as a guarantee of immutability, and for an &lt;code&gt;int&lt;/code&gt; or a &lt;code&gt;String&lt;/code&gt; it is one. For a &lt;code&gt;Map&lt;/code&gt; or a &lt;code&gt;List&lt;/code&gt; it guarantees only that you won't reassign the field.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two ways to be correct
&lt;/h2&gt;

&lt;p&gt;The rule is narrow: &lt;strong&gt;anything that feeds &lt;code&gt;hashCode&lt;/code&gt; must not change while the object could be in a hashed collection.&lt;/strong&gt; There are two honest ways to satisfy it.&lt;/p&gt;

&lt;p&gt;The first is to not put mutable state in equality at all. If &lt;code&gt;Tag&lt;/code&gt; is a handle to something (it owns a resource or wraps a big buffer you mutate in place), then value equality is the wrong model, and identity equality (the default, no &lt;code&gt;==&lt;/code&gt; override) is correct. Two handles are the same when they are the same object, and nothing can drift.&lt;/p&gt;

&lt;p&gt;The second, when the type really is a value and callers will compare it, is to make the state actually immutable by copying it in and handing back something no one can write to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Tag&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;Tag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;Map&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;Object&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;attrs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;attrs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;Map&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;Object&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;unmodifiable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;attrs&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kt"&gt;Map&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;Object&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;attrs&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="c1"&gt;// == and hashCode unchanged&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The constructor now takes the caller's map as a parameter and stores a separate, unmodifiable copy. The caller can do whatever they like to their map; it's not the one &lt;code&gt;Tag&lt;/code&gt; holds. And &lt;code&gt;tag.attrs['page'] = 3&lt;/code&gt; throws instead of silently corrupting the object. Rerun the first snippet against this version and &lt;code&gt;contains&lt;/code&gt; stays &lt;code&gt;true&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Map.unmodifiable&lt;/code&gt; copies the top level, which is what the hash reads. That is where it has to be. If you nest a mutable &lt;code&gt;List&lt;/code&gt; or &lt;code&gt;Map&lt;/code&gt; as a &lt;em&gt;value&lt;/em&gt; inside &lt;code&gt;attrs&lt;/code&gt;, that inner collection is still shared and still mutable. Either document that equality compares those values by identity or freeze them too. Be explicit about your choice. A half-frozen value type is its own trap.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this actually bites
&lt;/h2&gt;

&lt;p&gt;Nobody writes &lt;code&gt;attrs['page'] = 2&lt;/code&gt; right after building a &lt;code&gt;Tag&lt;/code&gt; on purpose. It happens at a distance. A store keeps the objects in a &lt;code&gt;Set&lt;/code&gt; or uses them as &lt;code&gt;Map&lt;/code&gt; keys, hands one back to you, and you update its metadata (reasonable, the field is right there), and now a &lt;code&gt;Set&lt;/code&gt; somewhere else silently can't find it. Or a builder reuses one map across several objects, filling it in between, and they all end up sharing state. The failure surfaces far from the mutation, as a lookup that misses or a deduplication that doesn't dedupe, with no error to trace back.&lt;/p&gt;

&lt;p&gt;The check is quick. For every type in your public API that overrides &lt;code&gt;==&lt;/code&gt;, ask what its &lt;code&gt;hashCode&lt;/code&gt; reads, and whether any of it can change after construction. If it can, either drop value equality or copy the mutable parts in. Add one test that puts an instance in a &lt;code&gt;Set&lt;/code&gt;, mutates the collection you handed the constructor, and asserts the instance is still found. The assertion is one line and it fails loudly the day someone reintroduces the reference.&lt;/p&gt;

</description>
      <category>dart</category>
      <category>flutter</category>
      <category>debugging</category>
      <category>programming</category>
    </item>
    <item>
      <title>My CI ran on three platforms and still could not catch this</title>
      <dc:creator>Yusuf İhsan Görgel</dc:creator>
      <pubDate>Fri, 24 Jul 2026 01:50:11 +0000</pubDate>
      <link>https://dev.to/yusufihsangorgel/my-ci-ran-on-three-platforms-and-still-could-not-catch-this-l04</link>
      <guid>https://dev.to/yusufihsangorgel/my-ci-ran-on-three-platforms-and-still-could-not-catch-this-l04</guid>
      <description>&lt;p&gt;For thirteen releases, my &lt;a href="https://github.com/Yusufihsangorgel/audio_decode" rel="noopener noreferrer"&gt;Ogg/MP3 decoder&lt;/a&gt;'s README said this:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;it decodes the same bytes to the same samples on every platform&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I believed it. The decoding happens in C, the C is compiled from the same source everywhere, the tests pass on macOS, Linux and Windows. Where would a difference even come from?&lt;/p&gt;

&lt;p&gt;I was getting the package ready to freeze at 1.0.0, which is the point where a documented promise stops being a sentence and becomes a contract. So instead of reading the claim again, I tested it. It is false, and it has been false since the first release.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing a claim about two architectures needs two architectures
&lt;/h2&gt;

&lt;p&gt;That sounds obvious written down. It is exactly what my CI did not do.&lt;/p&gt;

&lt;p&gt;On an Apple laptop you can do the whole thing locally, because clang will cross-compile and Rosetta will run the result. The driver ships in the repo at &lt;code&gt;doc/blog/cross-arch/detdrv.c&lt;/code&gt;; from a fresh clone, run these at the repo root:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cp &lt;/span&gt;doc/blog/cross-arch/detdrv.c &lt;span class="nb"&gt;.&lt;/span&gt;
clang &lt;span class="nt"&gt;-arch&lt;/span&gt; arm64  &lt;span class="nt"&gt;-O3&lt;/span&gt; &lt;span class="nt"&gt;-Isrc&lt;/span&gt;/third_party &lt;span class="nt"&gt;-o&lt;/span&gt; det_arm64  detdrv.c src/&lt;span class="k"&gt;*&lt;/span&gt;.c
clang &lt;span class="nt"&gt;-arch&lt;/span&gt; x86_64 &lt;span class="nt"&gt;-O3&lt;/span&gt; &lt;span class="nt"&gt;-Isrc&lt;/span&gt;/third_party &lt;span class="nt"&gt;-o&lt;/span&gt; det_x86_64 detdrv.c src/&lt;span class="k"&gt;*&lt;/span&gt;.c
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;detdrv.c&lt;/code&gt; is about fifty lines: read a file, call the package's own decode function, print the channel count, the sample rate, the frame count, and an FNV-1a hash of the decoded samples. Then run both binaries over six test fixtures and compare the hashes. &lt;code&gt;src/*.c&lt;/code&gt; here is the package's two C files. The build actually uses &lt;code&gt;-O3&lt;/code&gt;, making this the production configuration rather than a special one. The x86-64 binary runs under Rosetta, but Rosetta only executes the instructions it is handed; anything the binaries disagree on was decided when clang compiled them, not at run time.&lt;/p&gt;

&lt;p&gt;Five of those six came back different.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;fixture                      arm64             x86_64            same?
sine_44100_mono_1s.mp3       26f26e4d00bd4680  db3ed41489553717  NO
sine_44100_mono_1s.ogg       741546cc3f0e3857  9b9fa075856e40b1  NO
sine_44100_stereo_1s.mp3     62655177b196b90f  db39b9b9973061b7  NO
sine_44100_stereo_1s.ogg     b712d80468229e7f  ab1522a686a648df  NO
sine_48000_mono_halfsec.mp3  03a0cfb92eff3109  03a0cfb92eff3109  yes
sine_48000_mono_halfsec.ogg  0e38543dbad39c7e  db914b5a5a724d93  NO
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same source, same compiler, same optimisation level, same input bytes. Different samples. The one that matched, a short MP3, matched by luck rather than by rule: the divergence only shows up when a sample lands close enough to a rounding boundary to fall the other way, and that clip happened to have none.&lt;/p&gt;

&lt;h2&gt;
  
  
  How big, exactly
&lt;/h2&gt;

&lt;p&gt;A hash mismatch tells you nothing about severity. The next step is to dump both PCM buffers and diff them sample by sample:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sine_44100_mono_1s.ogg      12 of 44100 samples differ (0.027%), max delta 1 LSB
sine_44100_mono_1s.mp3       3 of 47232 samples differ (0.006%), max delta 1 LSB
sine_44100_stereo_1s.ogg    16 of 88200 samples differ (0.018%), max delta 1 LSB
sine_44100_stereo_1s.mp3    10 of 94464 samples differ (0.011%), max delta 1 LSB
sine_48000_mono_halfsec.ogg  5 of 24000 samples differ (0.021%), max delta 1 LSB
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is all five of the fixtures that diverged. Every difference is one least-significant bit, on a fraction of a percent of samples. In 16-bit audio one LSB is one part in 32768 of peak amplitude. Nobody is going to hear it. The frame count, channel count and sample rate matched on both architectures for all six fixtures.&lt;/p&gt;

&lt;p&gt;So the audio is fine. The sentence is not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the bit goes missing
&lt;/h2&gt;

&lt;p&gt;Both decoders do their arithmetic in floating point, and the C standard lets a compiler contract a multiply followed by an add into a single fused multiply-add. An &lt;code&gt;fma&lt;/code&gt; computes &lt;code&gt;a * b + c&lt;/code&gt; with one rounding instead of two. It is &lt;em&gt;more&lt;/em&gt; accurate and produces a different number than the unfused version.&lt;/p&gt;

&lt;p&gt;arm64 has an &lt;code&gt;fmadd&lt;/code&gt; instruction and clang uses it freely. Baseline x86-64 does not guarantee FMA. On that target the same expression compiles to a separate multiply and add, with a rounding in between. One rounding versus two, in a decoder that then scales to 16-bit integers, and occasionally a sample lands on the other side of &lt;code&gt;.5&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This is not a bug in the decoders this package vendors (&lt;code&gt;stb_vorbis&lt;/code&gt; for Ogg and &lt;code&gt;minimp3&lt;/code&gt; for MP3), nor in clang or my shim. It is the default behaviour of C floating point, and &lt;code&gt;-ffp-contract=off&lt;/code&gt; turns it off. I checked what the build actually passes: only an optimisation level and no floating-point flags. Contraction is on, and the divergence I had documented as impossible is exactly what the default asks for.&lt;/p&gt;

&lt;p&gt;For &lt;code&gt;stb_vorbis&lt;/code&gt; that flag is the whole story. Rebuilt with contraction off, all three Ogg fixtures decode identically on both architectures. But both MP3 fixtures &lt;strong&gt;still&lt;/strong&gt; differ. I disassembled the contraction-off arm64 build. It contains zero fused multiply-add instructions. Whatever is left cannot be a fused-versus-unfused split.&lt;/p&gt;

&lt;p&gt;It is &lt;code&gt;minimp3&lt;/code&gt; hand-writing its final float-to-int16 conversion once per instruction set. Its two versions round differently. The SSE path converts with &lt;code&gt;_mm_cvtps_epi32&lt;/code&gt;, which rounds to nearest and breaks ties to even. The NEON path adds &lt;code&gt;0.5f&lt;/code&gt; and truncates. The float values feeding that step are bit-identical on both architectures once contraction is off. I decoded to float on both and hashed the result to check. The entire residual difference is those two rounding rules disagreeing on a handful of samples by one. Force the plain scalar path with &lt;code&gt;-DMINIMP3_NO_SIMD&lt;/code&gt;, which also adds &lt;code&gt;0.5f&lt;/code&gt; and truncates. x86-64 now rounds the way the NEON path does, and all six fixtures finally match with contraction off as well.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part worth generalising
&lt;/h2&gt;

&lt;p&gt;My CI matrix is three jobs: macOS (arm64), Ubuntu (x64), Windows (x64). It looks like it covers this. It cannot.&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%2Fgw5thfm4qr8t1aea64w4.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%2Fgw5thfm4qr8t1aea64w4.png" alt="On the left, three CI jobs each running their own tests and passing, with nothing connecting them. On the right, the same three jobs feeding a hash of their decoded samples into one step that compares them against each other, which is where the arm64 and x64 mismatch appears" width="799" height="444"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Each job builds the package and runs the test suite, and the test suite checks that decoding produces the right frame count, the right sample rate, and a waveform whose dominant frequency is the tone it should be. Every one of those assertions is a comparison &lt;strong&gt;against itself, inside one job&lt;/strong&gt;. Nothing ever compares the macOS output against the Ubuntu output, because nothing in the matrix has both.&lt;/p&gt;

&lt;p&gt;The trap is not specific to audio:&lt;/p&gt;

&lt;p&gt;No number of independent self-comparisons tests a cross-environment claim. The only test is to carry an output from one environment into the other and compare. A matrix that never moves an output between jobs does not do that.&lt;/p&gt;

&lt;p&gt;The fix in CI, if you need the guarantee, is to have jobs upload a hash of the output and have a final job assert they all match. The fix in the docs, if you do not need the guarantee, is to stop claiming it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the docs say now
&lt;/h2&gt;

&lt;p&gt;I took the second option, because bit-identical cross-architecture output is not worth turning off contraction and &lt;code&gt;minimp3&lt;/code&gt;'s SIMD paths for a decoder whose output goes to a waveform or an FFT:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Decoding is deterministic for a given build: the same bytes decode to the same samples every time, and the geometry (channel count, sample rate, frame count) is the same everywhere. Sample values are not bit-identical across CPU architectures, though. [...] Measured across this package's own fixtures, that is around 0.03% of samples differing by one least-significant bit: inaudible, but enough that a checksum of decoded PCM will not match across a mixed-architecture fleet.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That last clause is the only case where any of this bites. If you decode audio on a build machine and compare a hash against audio decoded on a user's machine, or you cache by content hash across a mixed fleet, you will get misses that look like corruption and are not. Everyone else can ignore the whole thing, which is why the honest version of the sentence is longer than the false one.&lt;/p&gt;

&lt;p&gt;If you maintain a native package that computes in floating point, the check costs one afternoon: build for two architectures, hash the output, compare. I would not assume the answer.&lt;/p&gt;

</description>
      <category>testing</category>
      <category>c</category>
      <category>cicd</category>
      <category>debugging</category>
    </item>
    <item>
      <title>The 24.8 milliseconds you pay before ffmpeg decodes a single sample</title>
      <dc:creator>Yusuf İhsan Görgel</dc:creator>
      <pubDate>Fri, 24 Jul 2026 01:50:09 +0000</pubDate>
      <link>https://dev.to/yusufihsangorgel/the-248-milliseconds-you-pay-before-ffmpeg-decodes-a-single-sample-3983</link>
      <guid>https://dev.to/yusufihsangorgel/the-248-milliseconds-you-pay-before-ffmpeg-decodes-a-single-sample-3983</guid>
      <description>&lt;p&gt;You have an &lt;code&gt;.ogg&lt;/code&gt; file and you need the raw samples. In Dart there is no decoder in the standard library, so you do the normal thing: you shell out to ffmpeg, read its stdout, and get your PCM. It works, it is one line, and ffmpeg is better at codecs than anything you are going to write.&lt;/p&gt;

&lt;p&gt;I wanted to know what that costs, so I measured it against decoding in the same process. The interesting number turned out not to be the one I went looking for.&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%2Fwyd22w75sbzob5wm5u8z.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%2Fwyd22w75sbzob5wm5u8z.png" alt="Decoding the same Ogg file in process and by spawning ffmpeg, at four clip lengths. Every ffmpeg bar starts with the same 24.8 ms block of process startup, so at one second of audio almost none of the elapsed time is spent decoding" width="800" height="520"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The measurement
&lt;/h2&gt;

&lt;p&gt;Four clips of a 440 Hz sine, 44.1 kHz stereo, Vorbis in Ogg, at 1, 5, 15 and 30 seconds. Two ways to turn each one into interleaved 16-bit PCM:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;In process.&lt;/strong&gt; &lt;code&gt;decodeOgg(bytes)&lt;/code&gt; from my &lt;a href="https://pub.dev/packages/audio_decode" rel="noopener noreferrer"&gt;audio_decode&lt;/a&gt; package, which compiles &lt;a href="https://github.com/nothings/stb" rel="noopener noreferrer"&gt;stb_vorbis&lt;/a&gt; from source through a Dart build hook and calls it over FFI.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Subprocess.&lt;/strong&gt; &lt;code&gt;ffmpeg -i clip.ogg -f s16le -acodec pcm_s16le -&lt;/code&gt;, run with &lt;code&gt;Process.runSync&lt;/code&gt;, reading the PCM off stdout. ffmpeg 8.1.1.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Nine runs each, median reported, Apple M-series laptop. The bench checks that both paths produce the same amount of audio before it trusts either timing, which matters more than it sounds like and which I will come back to.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;clip&lt;/th&gt;
&lt;th&gt;in process&lt;/th&gt;
&lt;th&gt;ffmpeg&lt;/th&gt;
&lt;th&gt;ratio&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1 s&lt;/td&gt;
&lt;td&gt;0.594 ms&lt;/td&gt;
&lt;td&gt;25.221 ms&lt;/td&gt;
&lt;td&gt;42x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5 s&lt;/td&gt;
&lt;td&gt;2.108 ms&lt;/td&gt;
&lt;td&gt;27.406 ms&lt;/td&gt;
&lt;td&gt;13x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;15 s&lt;/td&gt;
&lt;td&gt;6.294 ms&lt;/td&gt;
&lt;td&gt;32.746 ms&lt;/td&gt;
&lt;td&gt;5x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;30 s&lt;/td&gt;
&lt;td&gt;12.923 ms&lt;/td&gt;
&lt;td&gt;40.452 ms&lt;/td&gt;
&lt;td&gt;3x&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Run to run these move by a few percent, and more than that on the sub-millisecond cell: a second run, from a fresh clone of the repo, gave 0.622 ms and 24.727 ms in the first row. Nothing below depends on the third decimal.&lt;/p&gt;

&lt;p&gt;A 42x ratio at one second is the sort of number that ends up in a README, and it would be a dishonest one. Look at what the ffmpeg column actually does: thirty times more audio, and the time goes up by 15 ms. The clip length is barely moving that number. Something else is most of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The floor
&lt;/h2&gt;

&lt;p&gt;The way to find out is to give ffmpeg nothing to do. I encoded a 0.05-second clip, 4 KB, and decoded it the same way:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Decoding a 0.05 s clip through ffmpeg still takes 24.793 ms, so that
is roughly what the process itself costs, before any audio is decoded.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the answer. About &lt;strong&gt;24.8 ms of the 25.2 ms&lt;/strong&gt; at one second is not decoding. It is &lt;code&gt;fork&lt;/code&gt;, &lt;code&gt;exec&lt;/code&gt;, the dynamic linker resolving ffmpeg's dependencies, ffmpeg parsing its arguments, probing the input format, setting up filters, and tearing all of that down again.&lt;/p&gt;

&lt;p&gt;Subtract it and the two decoders look like what they are, which is two competent implementations of the same codec:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;clip&lt;/th&gt;
&lt;th&gt;in process&lt;/th&gt;
&lt;th&gt;ffmpeg, decoding only&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1 s&lt;/td&gt;
&lt;td&gt;0.594 ms&lt;/td&gt;
&lt;td&gt;0.428 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5 s&lt;/td&gt;
&lt;td&gt;2.108 ms&lt;/td&gt;
&lt;td&gt;2.613 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;15 s&lt;/td&gt;
&lt;td&gt;6.294 ms&lt;/td&gt;
&lt;td&gt;7.953 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;30 s&lt;/td&gt;
&lt;td&gt;12.923 ms&lt;/td&gt;
&lt;td&gt;15.659 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;ffmpeg is faster than me at one second and I am faster at thirty. Nobody should switch decoders over that. The 24.8 ms is the whole story, and it has nothing to do with audio.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it actually costs you
&lt;/h2&gt;

&lt;p&gt;Because the floor is fixed per invocation, the arithmetic is per file, not per second of audio:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One 30-minute podcast:&lt;/strong&gt; ffmpeg spends ~25 ms starting up and around a second decoding. The startup is 2-3% of the job. Shell out; it is fine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A thousand short voice clips:&lt;/strong&gt; 1000 × 24.8 ms is &lt;strong&gt;about 25 seconds&lt;/strong&gt;, spent entirely on process startup, before counting a single sample. Batch them into one ffmpeg call and that goes away, but the moment the files arrive one at a time, or need different decode options, you are back to paying it per file. In process, it never exists.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A Flutter app decoding a clip in response to a tap:&lt;/strong&gt; 25 ms is a frame and a half at 60 fps, on top of whatever the decode costs, and you cannot amortise it because there is one clip.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last one is the case where it stops being an optimisation and becomes a design constraint. It is also the case where shelling out is often not available at all: on iOS you cannot spawn a process, so there the subprocess column is not a slower option, it is not an option.&lt;/p&gt;

&lt;p&gt;The honest summary is narrow. If you decode occasionally, or in a batch job where a few seconds of overhead disappears, run ffmpeg and enjoy its enormous format coverage. If you decode many files, or decode on a latency budget, or ship somewhere without a shell, the per-file floor is the reason to pull the decoder in process.&lt;/p&gt;

&lt;h2&gt;
  
  
  The check that made the numbers mean anything
&lt;/h2&gt;

&lt;p&gt;Before the bench times anything, it decodes the clip both ways and checks that each produced the same amount of audio. This felt like belt-and-braces when I wrote it. It was not.&lt;/p&gt;

&lt;p&gt;A first, stricter version of the check compared the raw sample counts and threw:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sample count differs for 1s: 88200 vs 87944
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;128 frames short, 256 samples across the two stereo channels, on the one-second clip only. Two decoders disagreeing about how much audio is in a file is exactly the kind of thing that quietly makes a benchmark meaningless, so I went and looked instead of loosening the check.&lt;/p&gt;

&lt;p&gt;The container settles it. &lt;code&gt;ffprobe&lt;/code&gt; reports &lt;code&gt;duration_ts=44100&lt;/code&gt; for that clip, which is the frame count the Ogg stream declares. My decoder returns 44100 frames. ffmpeg's raw PCM pipe delivers 43972, stopping 128 frames early, and only at the shortest clip:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1s:  declared 44100     audio_decode 44100     ffmpeg pipe 43972
5s:  declared 220500    audio_decode 220500    ffmpeg pipe 220500
15s: declared 661500    audio_decode 661500    ffmpeg pipe 661500
30s: declared 1323000   audio_decode 1323000   ffmpeg pipe 1323000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A tail-trimming edge case at very short durations. 128 frames is 2.9 ms of audio, so for most uses it does not matter. But it does mean the check has to be about how much audio each side produced, counted in frames, not about the samples matching byte for byte. The samples do not match byte for byte, and that is the second thing worth knowing: &lt;code&gt;stb_vorbis&lt;/code&gt; and ffmpeg's Vorbis decoder are two different decoders, so about half the samples differ by one least-significant bit. Both are correct; floating-point Vorbis synthesis simply does not have a single right answer to the last bit. A check that demanded identical samples would fail on every clip and tell you nothing.&lt;/p&gt;

&lt;p&gt;So the check counts frames: it reports the 128-frame gap rather than hiding it, and fails only if the two decoders ever disagree by more than about a thousand frames, a codec block's worth, which would mean one of them is genuinely dropping audio. A benchmark that does not check that both sides did the same work is measuring something, and you do not know what.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run it yourself
&lt;/h2&gt;

&lt;p&gt;The timings in this post come from a script in the package, not from my notes:&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/Yusufihsangorgel/audio_decode
&lt;span class="nb"&gt;cd &lt;/span&gt;audio_decode
dart run bench/vs_ffmpeg.dart
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It needs ffmpeg on your PATH and a C toolchain for the build hook, generates its own clips, and prints the table above plus the measured floor for your machine. If your floor is very different from 24.8 ms I would genuinely like to know: process startup varies a lot by OS and by how ffmpeg was built, and the whole argument in this post is downstream of that one number.&lt;/p&gt;

&lt;p&gt;None of this is specific to audio. Shell out to a tool in a hot path and you pay for the process, not the tool, at the same price whether you asked it to do a little work or a lot.&lt;/p&gt;

</description>
      <category>dart</category>
      <category>ffmpeg</category>
      <category>performance</category>
      <category>showdev</category>
    </item>
    <item>
      <title>Pinning the point you pinch: the one line behind anchored zoom</title>
      <dc:creator>Yusuf İhsan Görgel</dc:creator>
      <pubDate>Thu, 23 Jul 2026 20:41:57 +0000</pubDate>
      <link>https://dev.to/yusufihsangorgel/pinning-the-point-you-pinch-the-one-line-behind-anchored-zoom-m4l</link>
      <guid>https://dev.to/yusufihsangorgel/pinning-the-point-you-pinch-the-one-line-behind-anchored-zoom-m4l</guid>
      <description>&lt;p&gt;Open a photo on your phone and pinch to zoom. The spot between your fingers stays between your fingers while everything around it grows. Trackpad pinch, scroll-wheel zoom on a map, double-tap to zoom into a diagram: same feel. Whatever point you aim at holds still while the view scales around it.&lt;/p&gt;

&lt;p&gt;That behavior is anchored zoom, and it comes down to one line of arithmetic. This post is about that line. The example package is photo_zoom, which I maintain, but the math is the same whether you are building an image viewer, a map, a canvas, or a diagram editor.&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%2Fzdexo2i61pcixm8oimaa.gif" 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%2Fzdexo2i61pcixm8oimaa.gif" alt="GIF: a marked focal point staying fixed on screen while the scene scales around it, then pans" width="680" height="447"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That is a conceptual illustration of the behavior. It is not a screen recording of the Flutter widget. The marked point holds its spot on screen while the grid scales, then the whole view pans.&lt;/p&gt;

&lt;h2&gt;
  
  
  A view is a scale and a translation
&lt;/h2&gt;

&lt;p&gt;A zoomable view maps content coordinates (where a pixel sits inside the image or the map) to screen coordinates (where it lands on the display). In one dimension the entire mapping is one equation:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;screen = scale * content + translation&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;scale&lt;/code&gt; is how far you are zoomed in. &lt;code&gt;translation&lt;/code&gt; is how far the content is shifted. Two numbers per axis, and that is the whole transform.&lt;/p&gt;

&lt;p&gt;Now zoom in, meaning raise &lt;code&gt;scale&lt;/code&gt;, and leave &lt;code&gt;translation&lt;/code&gt; alone. Every content value gets multiplied by a bigger number, so every point moves, including the one you were looking at. The image balloons toward a corner and the thing you cared about slides away.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pin one point and solve for the rest
&lt;/h2&gt;

&lt;p&gt;Pick the point you want to hold still. Say it sits at content coordinate &lt;code&gt;F&lt;/code&gt;, and you want it to stay at screen coordinate &lt;code&gt;S&lt;/code&gt;. Put those into the mapping:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;S = scale * F + translation&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;You know &lt;code&gt;S&lt;/code&gt;, &lt;code&gt;F&lt;/code&gt;, and the new &lt;code&gt;scale&lt;/code&gt;. The only unknown is &lt;code&gt;translation&lt;/code&gt;, so solve for it:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;translation = S - scale * F&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;That is the line. Recompute it every time &lt;code&gt;scale&lt;/code&gt; changes, and the point at &lt;code&gt;F&lt;/code&gt; lands on &lt;code&gt;S&lt;/code&gt; on every frame. The translation is forced here: once the focal point and the scale are fixed, there is exactly one translation that keeps the point pinned. You never choose it directly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the focal point comes from
&lt;/h2&gt;

&lt;p&gt;A pinch hands you two things: a focal point, which is the midpoint between your two fingers, and a change in scale. Feed that focal point into &lt;code&gt;translation = S - scale * F&lt;/code&gt; and the image grows out from between your fingers instead of drifting toward a corner.&lt;/p&gt;

&lt;p&gt;The same formula covers the other zoom gestures you already use:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Double-tap to zoom: the focal point is where you tapped.&lt;/li&gt;
&lt;li&gt;Scroll-wheel or trackpad zoom on desktop: the focal point is the cursor.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Different input, same math. Whatever point you name as the focal point is the point that stays put.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two axes, no new ideas
&lt;/h2&gt;

&lt;p&gt;Screens are 2D, and the rule runs on each axis on its own. Same formula, twice:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;translationX = focalScreenX - scale * focalContentX&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;translationY = focalScreenY - scale * focalContentY&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;There is no cross-talk between X and Y. The point you pinch is pinned horizontally and vertically by the same subtraction.&lt;/p&gt;

&lt;h2&gt;
  
  
  The line, as code
&lt;/h2&gt;

&lt;p&gt;Here it is as a pure-Dart function. It takes the focal point in both coordinate spaces plus the scale, and returns the translation that pins it, as a record with &lt;code&gt;x&lt;/code&gt; and &lt;code&gt;y&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Given where the focal point is on screen and in the content, and the&lt;/span&gt;
&lt;span class="c1"&gt;// scale, return the translation that keeps the focal point pinned.&lt;/span&gt;
&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="kt"&gt;double&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;double&lt;/span&gt; &lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="n"&gt;anchoredTranslation&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="kd"&gt;required&lt;/span&gt; &lt;span class="kt"&gt;double&lt;/span&gt; &lt;span class="n"&gt;focalScreenX&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="kd"&gt;required&lt;/span&gt; &lt;span class="kt"&gt;double&lt;/span&gt; &lt;span class="n"&gt;focalScreenY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="kd"&gt;required&lt;/span&gt; &lt;span class="kt"&gt;double&lt;/span&gt; &lt;span class="n"&gt;focalContentX&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="kd"&gt;required&lt;/span&gt; &lt;span class="kt"&gt;double&lt;/span&gt; &lt;span class="n"&gt;focalContentY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="kd"&gt;required&lt;/span&gt; &lt;span class="kt"&gt;double&lt;/span&gt; &lt;span class="n"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="nl"&gt;x:&lt;/span&gt; &lt;span class="n"&gt;focalScreenX&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;focalContentX&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nl"&gt;y:&lt;/span&gt; &lt;span class="n"&gt;focalScreenY&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;focalContentY&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;scale&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;Read the body and it is the 2D formula from the last section. &lt;code&gt;focalScreenX - focalContentX * scale&lt;/code&gt; is &lt;code&gt;S - scale * F&lt;/code&gt; on the X axis, and the same on Y.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checking it holds
&lt;/h2&gt;

&lt;p&gt;Here is the check I ran. Drop this into the same file as the function above and run it. It fixes a focal point, zooms from scale 1.0 to 3.0, and pushes the pinned point back through &lt;code&gt;screen = scale * content + translation&lt;/code&gt; to confirm it lands in the same place:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;focalScreenX&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;200.0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;focalScreenY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;150.0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;focalContentX&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;300.0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;focalContentY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;400.0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;scale&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;3.0&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;anchoredTranslation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="nl"&gt;focalScreenX:&lt;/span&gt; &lt;span class="n"&gt;focalScreenX&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nl"&gt;focalScreenY:&lt;/span&gt; &lt;span class="n"&gt;focalScreenY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nl"&gt;focalContentX:&lt;/span&gt; &lt;span class="n"&gt;focalContentX&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nl"&gt;focalContentY:&lt;/span&gt; &lt;span class="n"&gt;focalContentY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nl"&gt;scale:&lt;/span&gt; &lt;span class="n"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;backX&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;scale&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;focalContentX&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;x&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;backY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;scale&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;focalContentY&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;y&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'scale &lt;/span&gt;&lt;span class="si"&gt;$scale&lt;/span&gt;&lt;span class="s"&gt; -&amp;gt; translation (&lt;/span&gt;&lt;span class="si"&gt;${t.x}&lt;/span&gt;&lt;span class="s"&gt;, &lt;/span&gt;&lt;span class="si"&gt;${t.y}&lt;/span&gt;&lt;span class="s"&gt;); '&lt;/span&gt;
        &lt;span class="s"&gt;'focal maps back to (&lt;/span&gt;&lt;span class="si"&gt;$backX&lt;/span&gt;&lt;span class="s"&gt;, &lt;/span&gt;&lt;span class="si"&gt;$backY&lt;/span&gt;&lt;span class="s"&gt;)'&lt;/span&gt;&lt;span class="p"&gt;);&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;Run it and you get two lines. At scale 1.0 the translation is &lt;code&gt;(-100.0, -250.0)&lt;/code&gt;. At scale 3.0 it is &lt;code&gt;(-700.0, -1050.0)&lt;/code&gt;. The translation moves a long way between the two, because at 3x the content has to shift much further to hold that one point in place. But the pinned point maps back to &lt;code&gt;(200.0, 150.0)&lt;/code&gt; at both scales. That equality, the same screen position at every scale, is the property the formula guarantees.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this lives in Flutter
&lt;/h2&gt;

&lt;p&gt;In Flutter the transform is a &lt;code&gt;Matrix4&lt;/code&gt;. A &lt;code&gt;Transform&lt;/code&gt; widget applies one, and &lt;code&gt;InteractiveViewer&lt;/code&gt; maintains one for you as you pan and zoom. The &lt;code&gt;scale&lt;/code&gt; and &lt;code&gt;translation&lt;/code&gt; from above are entries in that matrix. When you pinch inside an &lt;code&gt;InteractiveViewer&lt;/code&gt;, it is running this same focal-point arithmetic to pick the matrix for the next frame.&lt;/p&gt;

&lt;p&gt;photo_zoom, the package I maintain, is a drop-in replacement for photo_view that wires this up for image viewing. It reads the pinch, double-tap, and drag gestures, builds the anchored transform internally, and scales the image around the gesture's focal point. A caller uses &lt;code&gt;PhotoView&lt;/code&gt; with an &lt;code&gt;imageProvider&lt;/code&gt; and, if they want, &lt;code&gt;minScale&lt;/code&gt;, &lt;code&gt;maxScale&lt;/code&gt;, and &lt;code&gt;initialScale&lt;/code&gt; (typed as &lt;code&gt;PhotoViewScale&lt;/code&gt;). The anchored-zoom math stays inside; the caller gets zoom that holds where they pinched.&lt;/p&gt;

&lt;p&gt;To be plain about what that buys you: anchored zoom is standard behavior. photo_view does it, &lt;code&gt;InteractiveViewer&lt;/code&gt; does it, your phone's photo app does it. None of it is special to photo_zoom. The part worth carrying to other projects is the one line, &lt;code&gt;translation = S - scale * F&lt;/code&gt;. Once you have seen it you can add anchored zoom to a custom canvas, a diagram editor, or a map yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  A wrinkle: when a drag should pan and when it should dismiss
&lt;/h2&gt;

&lt;p&gt;Once you allow zooming, a drag has two possible jobs. It can pan the zoomed image, or it can dismiss the viewer and close the full-screen view. You have to decide which one a given drag means.&lt;/p&gt;

&lt;p&gt;The rule that feels right keys off whether there is anything to pan into. While the image is larger than the viewport, there is off-screen content to pan into, so a drag pans. When the image is at or below the fit scale, there is nothing to pan, so a drag can dismiss.&lt;/p&gt;

&lt;p&gt;photo_zoom 0.2.1 fixed a case here: dismiss had gone dead once the image was zoomed out below its initial scale. At that point there is nothing left to pan, so a drag should have been free to dismiss, and it was not. This is the kind of edge that shows up only once anchored zoom already works and you start spending time in the zoomed-out state.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep this one line
&lt;/h2&gt;

&lt;p&gt;If you keep one thing from this, keep &lt;code&gt;translation = S - scale * F&lt;/code&gt;. Recompute it whenever the scale changes, using the focal point the gesture hands you, and the point under your finger stays under your finger. The matrix, the widget, and the package are all wrappers around that subtraction.&lt;/p&gt;

</description>
      <category>flutter</category>
      <category>dart</category>
      <category>graphics</category>
      <category>math</category>
    </item>
    <item>
      <title>A public const constructor is the back door around your factory's validation</title>
      <dc:creator>Yusuf İhsan Görgel</dc:creator>
      <pubDate>Thu, 23 Jul 2026 20:15:24 +0000</pubDate>
      <link>https://dev.to/yusufihsangorgel/a-public-const-constructor-is-the-back-door-around-your-factorys-validation-2hh8</link>
      <guid>https://dev.to/yusufihsangorgel/a-public-const-constructor-is-the-back-door-around-your-factorys-validation-2hh8</guid>
      <description>&lt;p&gt;You put your validation in a factory. &lt;code&gt;Schema.string(pattern: ...)&lt;/code&gt; checks that the pattern is a real regex before it hands you an object. &lt;code&gt;Schema.enumeration(values)&lt;/code&gt; refuses an empty list and copies the one you pass in. The factory is the front door, and it has a bouncer.&lt;/p&gt;

&lt;p&gt;Then someone calls the constructor instead.&lt;/p&gt;

&lt;p&gt;If the concrete class has a public constructor sitting next to that factory, it is a second door into the same object, and that door has no bouncer. Everything the factory checked, the constructor skips. This is a general Dart trap, and I hit it in my own package, &lt;code&gt;instructor_dart&lt;/code&gt;. The interesting part is why the obvious fix does not work: the constructor is &lt;code&gt;const&lt;/code&gt;, and &lt;code&gt;const&lt;/code&gt; is exactly what stops it from defending itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;instructor_dart&lt;/code&gt; takes a plain-Dart schema and a prompt and returns a validated, correctly-typed Dart object from an LLM. You describe the shape you want with a small schema API:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="s"&gt;'package:instructor_dart/instructor_dart.dart'&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Schema&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;pattern:&lt;/span&gt; &lt;span class="sx"&gt;r'^[^@]+@[^@]+$'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;priority&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Schema&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;enumeration&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s"&gt;'low'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;'medium'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;'high'&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;Schema&lt;/code&gt; is a sealed base type with static factory methods: &lt;code&gt;Schema.string()&lt;/code&gt;, &lt;code&gt;Schema.integer()&lt;/code&gt;, &lt;code&gt;Schema.number()&lt;/code&gt;, &lt;code&gt;Schema.boolean()&lt;/code&gt;, &lt;code&gt;Schema.enumeration()&lt;/code&gt;, &lt;code&gt;Schema.list()&lt;/code&gt;, and &lt;code&gt;Schema.object()&lt;/code&gt;. Each one returns a concrete type: &lt;code&gt;StringSchema&lt;/code&gt;, &lt;code&gt;IntegerSchema&lt;/code&gt;, &lt;code&gt;EnumSchema&lt;/code&gt;, and so on.&lt;/p&gt;

&lt;p&gt;Those concrete types are where the problem lived.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two doors, one object
&lt;/h2&gt;

&lt;p&gt;Before 0.4.0, the concrete classes had public const constructors. So you could build a &lt;code&gt;StringSchema&lt;/code&gt; two ways:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;Schema.string(pattern: '(')&lt;/code&gt; goes through the factory, which validates.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;StringSchema(pattern: '(')&lt;/code&gt; goes straight to the constructor, which does not.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both gave you a &lt;code&gt;StringSchema&lt;/code&gt;. Only one of them checked anything. Two of the factory invariants were real, and both were skippable through the raw constructor.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The regex is validated eagerly.&lt;/strong&gt; &lt;code&gt;Schema.string(pattern: ...)&lt;/code&gt; runs &lt;code&gt;RegExp(pattern)&lt;/code&gt; at construction time. If you pass a broken pattern, you get a &lt;code&gt;FormatException&lt;/code&gt; right there, at the line where you wrote the mistake:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Schema.string runs RegExp(pattern) and throws FormatException on a bad pattern:&lt;/span&gt;
&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;bad&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Schema&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;pattern:&lt;/span&gt; &lt;span class="s"&gt;'('&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Call the raw &lt;code&gt;StringSchema(pattern: '(')&lt;/code&gt; constructor and that check never runs. The broken pattern gets stored and sits there until something tries to match against it, which is usually far away from the typo, in a stack trace that points at the matcher instead of at you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The enum set cannot be empty, and it gets copied.&lt;/strong&gt; &lt;code&gt;Schema.enumeration(values)&lt;/code&gt; throws &lt;code&gt;ArgumentError&lt;/code&gt; if &lt;code&gt;values&lt;/code&gt; is empty, and it copies the list with &lt;code&gt;List.unmodifiable&lt;/code&gt;. So the allowed set is never empty, and nobody can mutate it after the fact:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;priority&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Schema&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;enumeration&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s"&gt;'low'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;'medium'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;'high'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The raw &lt;code&gt;EnumSchema(const [])&lt;/code&gt; constructor skipped both. You could build an enum that matches nothing, or pass in a list you keep a reference to and change later, out from under the schema.&lt;/p&gt;

&lt;h2&gt;
  
  
  "Just make the constructor validate itself"
&lt;/h2&gt;

&lt;p&gt;This is the obvious idea, and in Dart it does not work, because these constructors are const.&lt;/p&gt;

&lt;p&gt;A const constructor is restricted. It can assign to fields and it can run &lt;code&gt;assert&lt;/code&gt; statements in its initializer list. That is the whole menu:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Port&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="c1"&gt;// A const constructor may assert. That is as far as it goes.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;Port&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="k"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;65535&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;&lt;code&gt;assert&lt;/code&gt; has two problems for real validation. It only runs in debug mode, so in a release build it is gone. And it takes a boolean condition, so it cannot express "parse this string as a regular expression and throw a &lt;code&gt;FormatException&lt;/code&gt; if it fails." There is no way to call &lt;code&gt;RegExp(pattern)&lt;/code&gt; from inside a const constructor, and no way to throw an &lt;code&gt;ArgumentError&lt;/code&gt; conditionally the way an ordinary method can.&lt;/p&gt;

&lt;p&gt;So the real checks have to live somewhere that can run real code: a factory, or a static method. That is the actual reason these packages reach for factories in the first place. The factory is the only place a check can live, and a public const constructor sits right beside it as a way around it.&lt;/p&gt;

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

&lt;p&gt;Make the concrete constructor library-private. Each concrete class now has a private named constructor, and the factory routes through it. In shape:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;StringSchema&lt;/code&gt; has &lt;code&gt;const StringSchema._(...)&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Schema.string(...)&lt;/code&gt; does its regex check, then calls &lt;code&gt;StringSchema._(...)&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;From a caller's side, the change is small and total. The factory still works exactly as before:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Still the way to do it:&lt;/span&gt;
&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Schema&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;pattern:&lt;/span&gt; &lt;span class="sx"&gt;r'^[^@]+@[^@]+$'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Writing &lt;code&gt;StringSchema(pattern: r'^[^@]+@[^@]+$')&lt;/code&gt; is now an analyzer error: the class has no accessible unnamed constructor. There is no second door to walk through.&lt;/p&gt;

&lt;p&gt;The concrete classes stay exported as types, and that part matters. You still get &lt;code&gt;StringSchema&lt;/code&gt; and &lt;code&gt;EnumSchema&lt;/code&gt; as return types, you can still &lt;code&gt;switch&lt;/code&gt; over the sealed &lt;code&gt;Schema&lt;/code&gt; hierarchy and match on them, and you can still read their fields. Closing the constructor closes construction only; the type is still yours to use.&lt;/p&gt;

&lt;p&gt;One more thing fell out of this. The factories do not take an &lt;code&gt;isOptional&lt;/code&gt; argument, so once the raw constructors were closed, the only way left to mark a field optional is the &lt;code&gt;.optional()&lt;/code&gt; method that was the intended one. Removing the public constructor removed the shortcut around that too.&lt;/p&gt;

&lt;h2&gt;
  
  
  Honest scope: this was mostly preventive
&lt;/h2&gt;

&lt;p&gt;I want to be exact about what this changed, because it did not fix five broken validators.&lt;/p&gt;

&lt;p&gt;Of the seven factories, only two enforced an invariant that the raw constructor skipped: &lt;code&gt;string&lt;/code&gt; with its regex check, and &lt;code&gt;enumeration&lt;/code&gt; with its non-empty-and-copy rule. Those two were real holes, and closing the constructor closed them.&lt;/p&gt;

&lt;p&gt;The other five (&lt;code&gt;integer&lt;/code&gt;, &lt;code&gt;number&lt;/code&gt;, &lt;code&gt;boolean&lt;/code&gt;, &lt;code&gt;list&lt;/code&gt;, &lt;code&gt;object&lt;/code&gt;) forwarded their arguments and validated nothing. For those, there was no bug to fix. Closing their constructors is preventive: it means that the day someone adds a check to &lt;code&gt;Schema.integer()&lt;/code&gt;, there is no public &lt;code&gt;IntegerSchema(...)&lt;/code&gt; sitting next to it to route around the check. The single construction path is set up before it carries anything worth protecting.&lt;/p&gt;

&lt;p&gt;So the count is small: two real checks locked, and five constructors closed before they ever guard anything, with all seven now built through one path.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this had to happen before 1.0.0
&lt;/h2&gt;

&lt;p&gt;Removing a public constructor is a breaking change. Any code that wrote &lt;code&gt;StringSchema(...)&lt;/code&gt; stops compiling.&lt;/p&gt;

&lt;p&gt;Once a package puts out a stable 1.0.0, you cannot take a public constructor away without going to 2.0.0. &lt;code&gt;instructor_dart&lt;/code&gt; is young, so the cost of the break right now rounds to zero. After 1.0.0 the same cleanup costs a major version and a migration for everyone who depends on you. This was the cheap moment, so I took it.&lt;/p&gt;

&lt;h2&gt;
  
  
  If a factory validates, close the constructor
&lt;/h2&gt;

&lt;p&gt;The specific bug was mine, but the shape is everywhere: any value type with a validating factory and a public constructor has a back door. If you write these in Dart, the mechanics are worth keeping in your head. Here is the &lt;code&gt;Port&lt;/code&gt; from earlier with the door closed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Port&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// The only real constructor, and it is private.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;Port&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;_&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// The public door, and it can run whatever checks it wants.&lt;/span&gt;
  &lt;span class="kd"&gt;factory&lt;/span&gt; &lt;span class="n"&gt;Port&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;65535&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="n"&gt;ArgumentError&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;'value'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;'must be 1..65535'&lt;/span&gt;&lt;span class="p"&gt;);&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;Port&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;_&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;);&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;Give the class a private named constructor (&lt;code&gt;ClassName._&lt;/code&gt;), route the factory through it, and if the type is part of a sealed hierarchy, keep the class exported so it still works as a type in returns and &lt;code&gt;switch&lt;/code&gt; arms. The const constructor cannot carry the validation itself, because const constructors can only assign and assert. The factory is where the checks live, and a public constructor next to it is a way around them.&lt;/p&gt;

&lt;p&gt;Go look at your own value types. If the validation is in the factory and the constructor is public, you have two doors, and callers will find the one without the bouncer.&lt;/p&gt;

</description>
      <category>dart</category>
      <category>programming</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>2.75 seconds or 2 microseconds: the same regex, two engines</title>
      <dc:creator>Yusuf İhsan Görgel</dc:creator>
      <pubDate>Thu, 23 Jul 2026 19:59:12 +0000</pubDate>
      <link>https://dev.to/yusufihsangorgel/275-seconds-or-2-microseconds-the-same-regex-two-engines-k2o</link>
      <guid>https://dev.to/yusufihsangorgel/275-seconds-or-2-microseconds-the-same-regex-two-engines-k2o</guid>
      <description>&lt;p&gt;Here is a regular expression: &lt;code&gt;(a+)+$&lt;/code&gt;. Here is an input: a string of 28 &lt;code&gt;a&lt;/code&gt; characters followed by one &lt;code&gt;!&lt;/code&gt;, which the pattern cannot match.&lt;/p&gt;

&lt;p&gt;Ask Dart's built-in &lt;code&gt;RegExp&lt;/code&gt; whether the pattern matches that input, and on my laptop it takes 2.75 seconds to return &lt;code&gt;false&lt;/code&gt;. Ask Google's RE2 engine the same question about the same bytes, and it returns &lt;code&gt;false&lt;/code&gt; in about 2 microseconds. Same pattern, same string, same answer. The only thing that changed is which engine did the matching, and it made the answer arrive roughly a million times sooner.&lt;/p&gt;

&lt;p&gt;Both numbers are real. I ran the benchmark on Apple Silicon with Dart 3.11, and both engines saw byte-for-byte identical input. The gap is not something you can close by writing a cleverer pattern. Here is where it comes from, and when it is worth paying RE2's cost to avoid it.&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%2Finfs040giya6mtiofiel.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%2Finfs040giya6mtiofiel.png" alt="Match time for the pattern (a+)+$ on a log scale: Dart's RegExp climbs exponentially to 2.75 seconds at 28 characters while re2 stays flat near 2 microseconds" width="800" height="480"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The measurement
&lt;/h2&gt;

&lt;p&gt;The pattern is &lt;code&gt;(a+)+$&lt;/code&gt;. The input is &lt;code&gt;n&lt;/code&gt; copies of the letter &lt;code&gt;a&lt;/code&gt; followed by one &lt;code&gt;!&lt;/code&gt;, so the match always fails (the string does not end the way the pattern demands). I fed the identical pattern and input to both engines and timed the match. Times are in microseconds; I write &lt;code&gt;us&lt;/code&gt; for microseconds and &lt;code&gt;ms&lt;/code&gt; for milliseconds.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;n&lt;/th&gt;
&lt;th&gt;Dart RegExp (us)&lt;/th&gt;
&lt;th&gt;re2 (us)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;td&gt;4,712&lt;/td&gt;
&lt;td&gt;2 to 3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;18&lt;/td&gt;
&lt;td&gt;10,490&lt;/td&gt;
&lt;td&gt;2 to 3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;td&gt;15,671&lt;/td&gt;
&lt;td&gt;2 to 3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;22&lt;/td&gt;
&lt;td&gt;49,416&lt;/td&gt;
&lt;td&gt;2 to 3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;24&lt;/td&gt;
&lt;td&gt;169,381&lt;/td&gt;
&lt;td&gt;2 to 3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;26&lt;/td&gt;
&lt;td&gt;679,067&lt;/td&gt;
&lt;td&gt;2 to 3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;28&lt;/td&gt;
&lt;td&gt;2,746,957 (2.75 s)&lt;/td&gt;
&lt;td&gt;2 to 3&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;re2 sits flat at 2 to 3 us the whole way down the column. Dart's &lt;code&gt;RegExp&lt;/code&gt; roughly quadruples every time &lt;code&gt;n&lt;/code&gt; goes up by two. By &lt;code&gt;n=28&lt;/code&gt; it is spending 2.75 seconds to answer a yes/no question about a 29-character string.&lt;/p&gt;

&lt;p&gt;This is the shape of a ReDoS (regular expression denial of service) bug. A pattern that looks ordinary meets an input that is a little bit adversarial, and one match call ties up the isolate for seconds. This is not hypothetical: in &lt;a href="https://github.com/dart-lang/sdk/issues/61284" rel="noopener noreferrer"&gt;dart-lang/sdk#61284&lt;/a&gt; an ordinary URL pattern froze a running app on iOS. Nobody wrote that pattern to be malicious. It just had the wrong shape and met the wrong input.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why one explodes and the other does not
&lt;/h2&gt;

&lt;p&gt;Both engines answer the same question. They go about it in completely different ways, and that difference is the entire 2.75-seconds-versus-2-microseconds gap.&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%2Flmgmjso9jvps3v9i4o3x.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%2Flmgmjso9jvps3v9i4o3x.png" alt="Side by side: a backtracking engine enumerating every way to split the input and backtracking through 134 million of them, versus RE2 making one left-to-right pass over a state machine in n steps" width="800" height="474"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  A backtracking engine treats matching as a search
&lt;/h3&gt;

&lt;p&gt;For &lt;code&gt;(a+)+&lt;/code&gt; to match a run of a's, the engine has to decide how to divide that run between the inner &lt;code&gt;a+&lt;/code&gt; and the outer repetition. Five a's could be one group of five, or four then one, or two then three, and so on. As long as more a's keep coming, the greedy path just extends and the division never has to be reconsidered; the engine takes the first split that works and moves on.&lt;/p&gt;

&lt;p&gt;The trouble starts at the character after the run. In our input that character is &lt;code&gt;!&lt;/code&gt;, and the pattern still needs &lt;code&gt;$&lt;/code&gt; (end of string) there. The match fails at that point. A backtracking engine does not give up there. It backs up and tries the next way of dividing the a's, then the next, then the next, on the theory that some other division might let the rest of the pattern match.&lt;/p&gt;

&lt;p&gt;The number of ways to split a run of &lt;code&gt;n&lt;/code&gt; a's into ordered groups is 2 to the power &lt;code&gt;n-1&lt;/code&gt;. At &lt;code&gt;n=28&lt;/code&gt; that is 2^27, which is 134,217,728 divisions the engine works through before it can be sure there is no match. Every extra &lt;code&gt;a&lt;/code&gt; doubles that count. That is where the "quadruple every time n goes up by two" in the table comes from: one more character, twice the work; two more, four times.&lt;/p&gt;

&lt;h3&gt;
  
  
  RE2 does not search
&lt;/h3&gt;

&lt;p&gt;RE2 compiles the pattern once into a finite-state machine. Then it reads the input one character at a time, and instead of tracking a single guess it tracks the entire set of states the machine could be in at that point. Each character advances that whole set by one step. The read pointer only ever moves forward; it never backs up to retry a division, because RE2 is not trying divisions at all. It is asking, after these characters, is any accepting state reachable.&lt;/p&gt;

&lt;p&gt;The linear-time guarantee is exactly this: &lt;code&gt;n&lt;/code&gt; characters take exactly &lt;code&gt;n&lt;/code&gt; steps, and no input, however it is crafted, can push an RE2 match into exponential time. There are no divisions to enumerate, so there is nothing to enumerate exponentially many of.&lt;/p&gt;

&lt;p&gt;The engine behind the &lt;code&gt;re2&lt;/code&gt; package is an FFI binding to &lt;a href="https://github.com/google/re2" rel="noopener noreferrer"&gt;Google's RE2&lt;/a&gt;, the same C++ engine, exposed to Dart.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why Dart's built-in RegExp is the backtracking kind
&lt;/h3&gt;

&lt;p&gt;Dart's &lt;code&gt;RegExp&lt;/code&gt; is Irregexp, the engine that came from V8. Irregexp is a backtracking engine, which is why it is fast and full-featured on ordinary patterns and can handle features like backreferences. The same design is what lets a nested quantifier like &lt;code&gt;(a+)+&lt;/code&gt; blow up. Every backtracking engine behaves this way when a nested quantifier fails to match. The exponential search is the algorithm working as designed. There is no patch for it; a backtracking engine does this by construction.&lt;/p&gt;

&lt;h2&gt;
  
  
  The million-character match
&lt;/h2&gt;

&lt;p&gt;Because RE2 takes one step per character, you can throw genuinely large input at it and watch the time stay reasonable. Each of these is &lt;code&gt;n&lt;/code&gt; a's followed by one non-matching character, the same failing shape as before, just longer:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;input length (n)&lt;/th&gt;
&lt;th&gt;re2 match time&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1,000&lt;/td&gt;
&lt;td&gt;50 us&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10,000&lt;/td&gt;
&lt;td&gt;138 us&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;100,000&lt;/td&gt;
&lt;td&gt;111 us&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1,000,000&lt;/td&gt;
&lt;td&gt;1,894 us (1.9 ms)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Those numbers do not climb smoothly. At the microsecond scale, fixed overhead (crossing into native code, marshalling the string) and ordinary measurement noise dominate, so &lt;code&gt;n=100,000&lt;/code&gt; can clock in under &lt;code&gt;n=10,000&lt;/code&gt;. The trend is the point: ten times the input is nowhere near ten times the time, and a million characters match in 1.9 ms.&lt;/p&gt;

&lt;p&gt;Put that same million-character hostile input in front of a backtracking engine on &lt;code&gt;(a+)+$&lt;/code&gt; and it would have 2^999999 divisions to work through, which is longer than the age of the universe. RE2 finishes it in under two milliseconds because it never asks that question.&lt;/p&gt;

&lt;h2&gt;
  
  
  Using it in Dart
&lt;/h2&gt;

&lt;p&gt;The API is small. You construct a &lt;code&gt;Re2&lt;/code&gt; from a pattern and call &lt;code&gt;hasMatch&lt;/code&gt;, &lt;code&gt;firstMatch&lt;/code&gt;, or &lt;code&gt;allMatches&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="s"&gt;'package:re2/re2.dart'&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// The classic catastrophic-backtracking pattern.&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Re2&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sx"&gt;r'(a+)+$'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;hostile&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;'a'&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;'!'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="n"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;hasMatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hostile&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt; &lt;span class="c1"&gt;// returns false in microseconds; the built-in&lt;/span&gt;
                               &lt;span class="c1"&gt;// RegExp would take seconds on the same input&lt;/span&gt;
  &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;dispose&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;I used 30 a's here; since each &lt;code&gt;a&lt;/code&gt; doubles the backtracking engine's work, 30 is well past the point where the built-in &lt;code&gt;RegExp&lt;/code&gt; stalls for seconds, while RE2 does not notice the difference.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Re2&lt;/code&gt; implements Dart's &lt;code&gt;Pattern&lt;/code&gt;, so an instance drops into &lt;code&gt;String.split&lt;/code&gt;, &lt;code&gt;String.replaceAll&lt;/code&gt;, and the other methods that take a &lt;code&gt;Pattern&lt;/code&gt;. Call &lt;code&gt;dispose&lt;/code&gt; when you are done with it; it holds a native resource behind the FFI boundary.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest trade-off
&lt;/h2&gt;

&lt;p&gt;RE2 is not a general speed boost for everyday regexes. On ordinary patterns and ordinary input, Dart's built-in &lt;code&gt;RegExp&lt;/code&gt; is usually the faster of the two, because every RE2 call has to cross the FFI boundary and marshal the string, and that costs something (roughly 2x in my measurements). What RE2 buys you is a ceiling on match time that holds no matter what the input is. That is a security property, and it only matters when the pattern or the input is outside your control. Most of your regexes run against strings you control, and the built-in &lt;code&gt;RegExp&lt;/code&gt; is the right tool for those.&lt;/p&gt;

&lt;p&gt;So the rule I use:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Reach for &lt;code&gt;re2&lt;/code&gt; when the pattern or the input is attacker-reachable: user-supplied search terms, validating uploaded content, a WAF-style ruleset, anything where someone else picks the string you match against.&lt;/li&gt;
&lt;li&gt;Keep the built-in &lt;code&gt;RegExp&lt;/code&gt; everywhere else, which is most places.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One constraint is worth knowing before you commit. RE2 does not support backreferences (&lt;code&gt;\1&lt;/code&gt;) or lookaround (&lt;code&gt;(?=...)&lt;/code&gt;, &lt;code&gt;(?&amp;lt;=...)&lt;/code&gt;) at all. Those are the features that force a backtracking implementation and open the door to the exponential blow-up, so RE2 leaves them out by design. A pattern using them throws a &lt;code&gt;FormatException&lt;/code&gt; the moment you construct the &lt;code&gt;Re2&lt;/code&gt;, long before it could bite you in production.&lt;/p&gt;

&lt;p&gt;The 2.75-seconds-versus-2-microseconds gap comes from RE2 refusing to enumerate divisions at all, which is a game a backtracking engine has no way to refuse. Once a regex runs against input someone else chose, the exponential case turns from a curiosity into an availability bug, and a linear-time engine earns its FFI cost. The package is &lt;a href="https://pub.dev/packages/re2" rel="noopener noreferrer"&gt;re2&lt;/a&gt; on pub.dev; the engine underneath is the one Google wrote for exactly this problem.&lt;/p&gt;

&lt;p&gt;Disclosure: &lt;code&gt;re2&lt;/code&gt; is my package; the RE2 engine it binds is Google's.&lt;/p&gt;

</description>
      <category>dart</category>
      <category>flutter</category>
      <category>regex</category>
      <category>security</category>
    </item>
    <item>
      <title>A finite loop in skeleton_shimmer that stopped resuming when the loop count was raised</title>
      <dc:creator>Yusuf İhsan Görgel</dc:creator>
      <pubDate>Thu, 23 Jul 2026 18:10:24 +0000</pubDate>
      <link>https://dev.to/yusufihsangorgel/a-finite-loop-in-skeletonshimmer-that-stopped-resuming-when-the-loop-count-was-raised-44ib</link>
      <guid>https://dev.to/yusufihsangorgel/a-finite-loop-in-skeletonshimmer-that-stopped-resuming-when-the-loop-count-was-raised-44ib</guid>
      <description>&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%2Fdqurtx0e8kduwrrsfyff.gif" 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%2Fdqurtx0e8kduwrrsfyff.gif" alt="An animated illustration of two skeleton cards, the left frozen after a finite loop finishes and the right still sweeping" width="799" height="373"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The clip above is a conceptual illustration of the behavior, with a card frozen after a finite loop on the left and a card still sweeping on the right. It is not a screen recording of the running widget.&lt;/p&gt;

&lt;p&gt;skeleton_shimmer draws a shimmer loading effect, a light gradient that sweeps across gray skeleton placeholders while data loads. It is API-compatible with the shimmer package. Its &lt;code&gt;Shimmer&lt;/code&gt; widget takes a &lt;code&gt;loop&lt;/code&gt; parameter: &lt;code&gt;loop: 0&lt;/code&gt; sweeps forever, and &lt;code&gt;loop: N&lt;/code&gt; stops after N sweeps.&lt;/p&gt;

&lt;p&gt;Version 0.2.1 had a bug in the &lt;code&gt;loop: N&lt;/code&gt; path. Once a finite loop finished, raising &lt;code&gt;loop&lt;/code&gt; at runtime did not start the sweep again. The widget stayed frozen on a static gray field. Version 0.2.2 fixes it with a one-line change, and the reason that line works comes down to how &lt;code&gt;AnimationController&lt;/code&gt; behaves when it is parked at one of its bounds.&lt;/p&gt;

&lt;h2&gt;
  
  
  The symptom
&lt;/h2&gt;

&lt;p&gt;Give the widget &lt;code&gt;loop: 2&lt;/code&gt; and let both sweeps play. The shimmer settles on gray, which is what &lt;code&gt;loop: 2&lt;/code&gt; asks for. Now raise the count to &lt;code&gt;loop: 4&lt;/code&gt; while the widget is on screen. Before 0.2.2, nothing happened. The placeholder stayed gray and static, and the two extra sweeps never ran.&lt;/p&gt;

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

&lt;p&gt;The sweep is driven by an &lt;code&gt;AnimationController&lt;/code&gt; that animates from its lower bound to its upper bound, 0.0 to 1.0. One pass from 0.0 to 1.0 is one sweep.&lt;/p&gt;

&lt;p&gt;A status listener, &lt;code&gt;_onStatus&lt;/code&gt;, counts completed passes. Each time the controller reaches the end it increments a completed-loops counter, &lt;code&gt;_completedLoops&lt;/code&gt;. While that counter is below the target it calls &lt;code&gt;forward(from: 0)&lt;/code&gt; to run the next sweep. Once the counter reaches the target it stops calling &lt;code&gt;forward(from: 0)&lt;/code&gt;, and the controller is left parked at the upper bound: &lt;code&gt;value&lt;/code&gt; is 1.0, its status is completed, and it is not animating. For &lt;code&gt;loop: N&lt;/code&gt; that is correct, since the animation is meant to stop after N sweeps.&lt;/p&gt;

&lt;p&gt;Raising &lt;code&gt;loop&lt;/code&gt; at runtime goes through &lt;code&gt;didUpdateWidget&lt;/code&gt;. That resets the completed-loops counter back to 0 and calls a resync method, &lt;code&gt;_syncAnimation&lt;/code&gt;, to line the animation up with the new configuration.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;_syncAnimation&lt;/code&gt; saw that the controller was not animating and tried to resume it with a bare &lt;code&gt;forward()&lt;/code&gt;. That is where it went wrong. &lt;code&gt;forward()&lt;/code&gt; animates toward the upper bound, and the controller was already sitting at the upper bound with &lt;code&gt;value&lt;/code&gt; at 1.0. The remaining distance was zero, so the call was a zero-duration no-op: it scheduled no ticker and did not fire the status listener again. The sweep never restarted, and the widget stayed frozen.&lt;/p&gt;

&lt;h2&gt;
  
  
  The framework behavior underneath it
&lt;/h2&gt;

&lt;p&gt;This is deterministic, and you can confirm it away from the package with a plain &lt;code&gt;AnimationController&lt;/code&gt;. Calling &lt;code&gt;forward()&lt;/code&gt; when &lt;code&gt;value&lt;/code&gt; is already 1.0 does nothing: the status stays completed and &lt;code&gt;isAnimating&lt;/code&gt; stays false. It is a genuine no-op. Calling &lt;code&gt;forward(from: 0)&lt;/code&gt; instead sets &lt;code&gt;value&lt;/code&gt; to 0.0 and &lt;code&gt;isAnimating&lt;/code&gt; to true, and the controller runs again.&lt;/p&gt;

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

&lt;p&gt;The change is one line in &lt;code&gt;_syncAnimation&lt;/code&gt;. The branch before 0.2.2:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;_controller&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isAnimating&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;_controller&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;forward&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;And after:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;_controller&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isAnimating&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// A finished finite loop parks the controller at the upper bound, where&lt;/span&gt;
    &lt;span class="c1"&gt;// forward() would be a no-op; restart from 0 so a raised loop count&lt;/span&gt;
    &lt;span class="c1"&gt;// sweeps again. Otherwise (e.g. re-enabled mid-sweep) resume in place.&lt;/span&gt;
    &lt;span class="n"&gt;_controller&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;forward&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;from:&lt;/span&gt; &lt;span class="n"&gt;_controller&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isCompleted&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;_controller&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;value&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;The ternary picks a starting point based on where the controller is parked.&lt;/p&gt;

&lt;p&gt;When the controller is completed, meaning it is parked at the upper bound after a finished finite loop, &lt;code&gt;forward(from: 0)&lt;/code&gt; restarts it from the beginning. That is the case the old code got wrong, and it matches what the status listener already does between loops.&lt;/p&gt;

&lt;p&gt;When the controller is not completed, for example it was stopped mid-sweep because the widget was disabled and is now being re-enabled, &lt;code&gt;forward(from: _controller.value)&lt;/code&gt; starts from the value the controller already holds. Setting the start to the current value is a no-op assignment, and the controller forwards from there. For that path the behavior is identical to the old bare &lt;code&gt;forward()&lt;/code&gt;. Only the completed case changed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two behaviors that had to keep working
&lt;/h2&gt;

&lt;p&gt;First, toggling the widget's &lt;code&gt;enabled&lt;/code&gt; flag off and back on in the middle of a sweep resumes in place rather than jumping to the start. A controller stopped mid-sweep is not completed, so the fix sends it down the resume-in-place branch, &lt;code&gt;forward(from: _controller.value)&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Second, reduced motion still holds. When the platform's &lt;code&gt;disableAnimations&lt;/code&gt; accessibility setting is on, the shimmer freezes on its base color. That path calls &lt;code&gt;_controller.stop()&lt;/code&gt;, and the fix does not touch it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The regression test
&lt;/h2&gt;

&lt;p&gt;A regression test shipped with the fix for exactly this scenario. It mounts the widget with &lt;code&gt;loop: 2&lt;/code&gt; and pumps until the animation settles. Once the finite loop is done the controller stops, so the transient callback count, the number of one-shot callbacks registered for the next frame, falls back to 0. The test then rebuilds with &lt;code&gt;loop: 4&lt;/code&gt; and checks that the sweep resumes, which shows up as the transient callback count going above 0 again.&lt;/p&gt;

&lt;p&gt;On the pre-fix code the count stays at 0, because the frozen controller schedules no ticker. After the fix it goes back above 0. I confirmed this by reverting only the fix and rerunning: the test fails before and passes after.&lt;/p&gt;

&lt;h2&gt;
  
  
  An AnimationController parked at a bound is a quiet trap
&lt;/h2&gt;

&lt;p&gt;Calling &lt;code&gt;forward()&lt;/code&gt; toward the bound you are already at is a no-op, and so is &lt;code&gt;reverse()&lt;/code&gt; toward the bound you are already at, because the remaining distance is zero. When you want the controller to run again from that bound, you have to say so: &lt;code&gt;forward(from: 0)&lt;/code&gt; from the upper bound, or &lt;code&gt;reverse(from: 1)&lt;/code&gt; from the lower bound.&lt;/p&gt;

&lt;p&gt;The other half of this bug sits in &lt;code&gt;didUpdateWidget&lt;/code&gt;. When a runtime parameter change resets internal state, here the completed-loops counter, it has to re-establish the animation to match the new state. Resuming a controller and restarting it are different operations, and the code has to choose the right one for where the controller is parked at that moment.&lt;/p&gt;

</description>
      <category>dart</category>
      <category>flutter</category>
      <category>animation</category>
    </item>
  </channel>
</rss>
