<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Wasm on PHP Boy Scout</title><link>https://phpboyscout.uk/tags/wasm/</link><description>Recent content in Wasm on PHP Boy Scout</description><generator>Hugo -- gohugo.io</generator><language>en-gb</language><copyright>Matt Cockayne</copyright><lastBuildDate>Thu, 09 Jul 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://phpboyscout.uk/tags/wasm/index.xml" rel="self" type="application/rss+xml"/><item><title>The afternoon the agent earned its keep</title><link>https://phpboyscout.uk/the-afternoon-the-agent-earned-its-keep/</link><pubDate>Thu, 09 Jul 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/the-afternoon-the-agent-earned-its-keep/</guid><description>&lt;img src="https://phpboyscout.uk/the-afternoon-the-agent-earned-its-keep/cover-the-afternoon-the-agent-earned-its-keep.png" alt="Featured image of post The afternoon the agent earned its keep" /&gt;&lt;p&gt;There&amp;rsquo;s a kind of bug that&amp;rsquo;s worse than a crash. A crash at least tells you &lt;em&gt;where&lt;/em&gt;. This one just&amp;hellip; stopped. I asked &lt;a class="link" href="https://phpboyscout.uk/introducing-afmpeg-and-ffmpeg-wasi/" &gt;ffmpeg-wasi&lt;/a&gt; to remux a clip into a Matroska file, no re-encoding, just repackage the same streams into an &lt;code&gt;.mkv&lt;/code&gt;, and it never came back. No error, no output. The test harness eventually gave up and shot it.&lt;/p&gt;
&lt;p&gt;The same job to an &lt;code&gt;.mp4&lt;/code&gt;? A dozen milliseconds. Change the file extension and a blink becomes a heat-death-of-the-universe operation.&lt;/p&gt;
&lt;p&gt;Normally this is the moment a war story starts costing me evenings. FFmpeg compiled to &lt;code&gt;wasm32-wasi&lt;/code&gt;, running under &lt;a class="link" href="https://wazero.io/" target="_blank" rel="noopener"
 &gt;wazero&lt;/a&gt;, reading and writing through an in-memory filesystem&amp;hellip; that&amp;rsquo;s a lot of layers, any of which could be the one that loops, and the traditional way to find out is to build test rigs for each layer by hand and lose a week to the plumbing. I know what that costs because I&amp;rsquo;ve paid it before.&lt;/p&gt;
&lt;p&gt;This time I didn&amp;rsquo;t pay it. The hang surfaced while an agent was implementing stream-copy for me, so rather than pull the work back onto my own bench, I told it to chase the bug too. What follows is the deduction it ran. My contribution was two decisions at two forks, and I&amp;rsquo;ll point at them as we pass.&lt;/p&gt;
&lt;h2 id="it-got-it-wrong-first"&gt;It got it wrong first
&lt;/h2&gt;&lt;p&gt;I want to start with the wrong turn, because it&amp;rsquo;s the most instructive part.&lt;/p&gt;
&lt;p&gt;The agent&amp;rsquo;s first theory was confident and detailed: it traced the guest&amp;rsquo;s filesystem calls, saw the Matroska muxer seeking back and forth rewriting its header, and declared the finalisation seek-backs the culprit, an I/O pattern my filesystem bridge couldn&amp;rsquo;t satisfy. Plausible. Specific. Wrong.&lt;/p&gt;
&lt;p&gt;And here&amp;rsquo;s the bit that sold me: it then designed the experiment that killed its own theory. Matroska has a streamable mode that writes without any seek-backs at all, so it flipped that on&amp;hellip; and the job still hung. Theory dead, evidence in hand, no sulking. I&amp;rsquo;ve watched humans (fine, me) defend a pet theory for two days past its expiry date. The agent binned its own in minutes and moved on to the thing you should always do when guessing stops working: bisect.&lt;/p&gt;
&lt;h2 id="ruling-out-the-world-one-build-at-a-time"&gt;Ruling out the world, one build at a time
&lt;/h2&gt;&lt;p&gt;This was my first fork. The agent asked whether to keep poking at it in place or to bring in native tooling, and I chose native. So it built a &lt;em&gt;real&lt;/em&gt; FFmpeg, same n8.1.2 source, same minimal configure flags, and started eliminating suspects:&lt;/p&gt;
&lt;table&gt;
 &lt;thead&gt;
 &lt;tr&gt;
 &lt;th&gt;It tested&lt;/th&gt;
 &lt;th&gt;Result&lt;/th&gt;
 &lt;th&gt;Which ruled out&lt;/th&gt;
 &lt;/tr&gt;
 &lt;/thead&gt;
 &lt;tbody&gt;
 &lt;tr&gt;
 &lt;td&gt;Native FFmpeg, 64-bit, real files → mkv&lt;/td&gt;
 &lt;td&gt;works&lt;/td&gt;
 &lt;td&gt;the muxer / our configure flags&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;wasm backed by a &lt;strong&gt;real OS directory&lt;/strong&gt;&lt;/td&gt;
 &lt;td&gt;hangs&lt;/td&gt;
 &lt;td&gt;the in-memory filesystem&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;Native FFmpeg, &lt;strong&gt;32-bit&lt;/strong&gt; (&lt;code&gt;-m32&lt;/code&gt;) → mkv&lt;/td&gt;
 &lt;td&gt;works&lt;/td&gt;
 &lt;td&gt;a 32-bit integer overflow&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;wasm under wazero&amp;rsquo;s &lt;strong&gt;interpreter&lt;/strong&gt;&lt;/td&gt;
 &lt;td&gt;hangs&lt;/td&gt;
 &lt;td&gt;a code-generation bug&lt;/td&gt;
 &lt;/tr&gt;
 &lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Sit with that table for a second. Two of those rows are &lt;em&gt;whole native builds of FFmpeg&lt;/em&gt;, one of them 32-bit with the toolchain to match, spun up as casually as you&amp;rsquo;d write a unit test. That&amp;rsquo;s the part that would have eaten my week. Each row walks an interesting suspect free, until the only thing left holding the bag is the plainest fact in the whole setup: this is running under WASI. Not the muxer. Not the maths. Not my filesystem. Not the runtime. The environment.&lt;/p&gt;
&lt;h2 id="the-environment-was-missing-a-number"&gt;The environment was missing a number
&lt;/h2&gt;&lt;p&gt;WASI is a smaller world than POSIX. No threads, no network, and, it turns out, no &lt;code&gt;/dev/urandom&lt;/code&gt;. Most code never notices. Right up until it does.&lt;/p&gt;
&lt;p&gt;With the bisection result in hand, the agent went and read the Matroska muxer&amp;rsquo;s source. Every Matroska track carries a unique random identifier, so at startup the muxer seeds a pseudo-random number generator via libavutil&amp;rsquo;s &lt;code&gt;av_get_random_seed()&lt;/code&gt;. In a build configured as tightly as ours, that function has exactly &lt;strong&gt;one&lt;/strong&gt; source of real entropy compiled in: reading &lt;code&gt;/dev/urandom&lt;/code&gt;. The Windows crypto API, &lt;code&gt;arc4random&lt;/code&gt;, OpenSSL, libgcrypt&amp;hellip; all configured out. Just the one device.&lt;/p&gt;
&lt;p&gt;Which WASI doesn&amp;rsquo;t have. So the read fails, and the function falls back to its last resort, &lt;a class="link" href="https://github.com/FFmpeg/FFmpeg/blob/n8.1.2/libavutil/random_seed.c#L75" target="_blank" rel="noopener"
 &gt;&lt;code&gt;get_generic_seed()&lt;/code&gt;&lt;/a&gt;: a genuinely clever routine that manufactures entropy out of thin air by reading &lt;code&gt;clock()&lt;/code&gt; in a tight loop and harvesting the jitter in how it advances. It&amp;rsquo;s a lovely trick. It has one requirement. The clock has to advance.&lt;/p&gt;
&lt;p&gt;Under WASI, &lt;code&gt;clock()&lt;/code&gt; doesn&amp;rsquo;t advance. The seeding call never returns. The muxer hangs before it has written a single byte, and the &lt;code&gt;.mp4&lt;/code&gt; muxer never asks for randomness at all, which is the entire reason it was fine all along.&lt;/p&gt;
&lt;p&gt;The agent didn&amp;rsquo;t leave that as a theory either. It wired &lt;code&gt;av_get_random_seed()&lt;/code&gt; into a trivial diagnostic op, one that does nothing else&amp;hellip; and that hung too. Root cause, demonstrated in isolation.&lt;/p&gt;
&lt;h2 id="a-device-not-a-patch"&gt;A device, not a patch
&lt;/h2&gt;&lt;p&gt;The fix isn&amp;rsquo;t a patch to FFmpeg. It&amp;rsquo;s &lt;a class="link" href="https://gitlab.com/phpboyscout/afmpeg/-/blob/b9b7d5e/internal/vfs/random.go#L32-40" target="_blank" rel="noopener"
 &gt;a device&lt;/a&gt;. My filesystem bridge already fabricates a couple of the POSIX fixtures a guest expects (&lt;code&gt;/tmp&lt;/code&gt; routed to scratch space, &lt;code&gt;/dev/null&lt;/code&gt; as a discard sink), so &lt;code&gt;/dev/urandom&lt;/code&gt; is &lt;a class="link" href="https://afmpeg.phpboyscout.uk/explanation/components/vfs-bridge/#why-devurandom-is-load-bearing" target="_blank" rel="noopener"
 &gt;the same trick&lt;/a&gt; with real randomness behind it, backed by Go&amp;rsquo;s &lt;code&gt;crypto/rand&lt;/code&gt;:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// Read fills buf with random bytes; a short read never happens (crypto/rand&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// fills the whole slice or errors), matching /dev/urandom&amp;#39;s contract.&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;func&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nx"&gt;randFile&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;Read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;buf&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;byte&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="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;experimentalsys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Errno&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&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;	&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;:=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;rand&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;buf&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;!=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;nil&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&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;		&lt;/span&gt;&lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;experimentalsys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;EIO&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&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&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;	&lt;/span&gt;&lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;buf&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Now the muxer&amp;rsquo;s very first entropy read succeeds, the seed returns immediately, and the clever-but-doomed clock loop is never reached. The &lt;code&gt;.mkv&lt;/code&gt; remux that had been hanging forever finishes in ten milliseconds. And the fix isn&amp;rsquo;t Matroska-specific: any format that wants a random seed now gets one for free.&lt;/p&gt;
&lt;h2 id="what-the-afternoon-actually-cost"&gt;What the afternoon actually cost
&lt;/h2&gt;&lt;p&gt;Here&amp;rsquo;s the arithmetic that made me want to write this up.&lt;/p&gt;
&lt;p&gt;From the first observed hang to a verified fix was a little over an hour. That hour included the wrong theory and the experiment that disproved it, two complete native builds of FFmpeg (one of them 32-bit), runs across two wazero execution modes, a read through libavutil&amp;rsquo;s entropy code, the diagnostic op that proved the root cause in isolation, and the fix itself. The agent even went looking for prior art and couldn&amp;rsquo;t find anyone who&amp;rsquo;d written this failure up&amp;hellip; a genuinely undocumented corner of WASI, run to ground between lunch and tea.&lt;/p&gt;
&lt;p&gt;By hand? Most of a week, and the bulk of it spent on build plumbing rather than thinking: getting a matching native FFmpeg to configure, wrangling a 32-bit toolchain, wiring the trace points. None of that work is hard. It&amp;rsquo;s just &lt;em&gt;time&lt;/em&gt;, and it&amp;rsquo;s exactly the kind of time a solo developer doesn&amp;rsquo;t have lying around, which is how deep bugs like this end up parked on a &amp;ldquo;someday&amp;rdquo; list with the &lt;code&gt;.mkv&lt;/code&gt; button greyed out.&lt;/p&gt;
&lt;p&gt;My whole part was two forks (dig in place, or go native? and later: document it before touching anything else) and reading the result. The judgement stayed mine. The legwork didn&amp;rsquo;t.&lt;/p&gt;
&lt;p&gt;I&amp;rsquo;ve &lt;a class="link" href="https://phpboyscout.uk/the-off-switch-was-never-a-button/" &gt;compared these agents to Golden Retrievers before&lt;/a&gt;, and I stand by it: eager, tireless, loyal to their training, occasionally proud of a shredded cushion. A dog that plays fetch all day is good company. But this one went down the burrow after something I couldn&amp;rsquo;t see, and came back with the exact rabbit.&lt;/p&gt;
&lt;p&gt;Good boy.&lt;/p&gt;</description></item></channel></rss>