Latest Posts (20 found)
Xe Iaso 3 weeks ago

This is why we play

Lately I've been playing classic World of Warcraft to get a feel for it and see if I like it before I try and go head-first into WoW Forever fairly blind with some friends. People have told me that World of Warcraft is good, but I haven't personally played it (or really any MMO outside of Final Fantasy 14). I've wanted to see for myself, so I dug in. I've been leveling an undead warlock and recently I've gotten my way to The Barrens. My goal was to kill some planestriders and collect their beaks as proof of my kills so that the population can get back under control or whatever. I wandered around and found another player also on that quest and waved to them. He was a gnome warlock named Borton and waved at me when I walked over. I waved back and invited him to a party. We walked around the area and started spreading our aggro to farm the planestriders and grab their drops. After a few kills he stopped for a second and said "this is why we play". I was taken a bit aback by this. It felt out of nowhere but also felt like one of the more human encounters I've had with people in a video game in a long time. One of the great parts about MMOs is that you end up having a lot of these itinerant bits of community like this. You both get a quest from an NPC asking you to go and farm some drops and then this sends you all out into the world to naturally do it together. These conversations and liminal bits of community end up adding up into the greater experiences and really giving you the flavour of the game's vibe. You do it together for the fun of the game. After a while we finished our farming, got all the planestrider beaks, commiserated about the drop tables, and decided to just sit down on a nearby hill and take a screenshot because it just felt right. Two players sitting in golden fields with a backdrop of mountains and a blue sky enjoying the moment together wordlessly. This has really gotten me thinking about what I really want to get out of games and why I play them. I think I've been doing it wrong. At some level the “high end” content of online games like Final Fantasy 14 or Retail World of Warcraft looks endless. There’s intricate dances of choreography as you and your friends puzzle your way through each boss mechanic towards victory. Hell, games like Final Fantasy 14 have their fights defined by the little call->response pairs as the boss telegraphs things and you react to it in concert. Not to mention the feeling of utter serotonin that comes from figuring it out and winning. One of the main problems with this is that the “high end” raids in this game tend to be a mile wide in theory but practically an inch deep. Don’t get me wrong, even as far back as a decade ago there’s still encounters that are actually hard (eg: XIV’s ultimate raids, I'm personally at Quickmarch Trio in UCOB if you have the right brainworms to know what that means), but as the fights get older there’s a sharp dropoff in the number of people that actually want to run them. This ends up with entire subsystems of the game (Deep Dungeons, Bozja before Shadowbringers went into the free trial, etc) becoming “dead content” because if you queue for them, nobody shows up. If you put up an entry for them in Party Finder, nobody will join you unless you set aside time to wait for hours. You end up with modes where you're encouraged to do it in a group being bereft of people so you're "forced" to figure it out on your own. As a result everyone is funneled into the high end and trying to min-max it as much as possible to squeeze all the game they can out as fast as they can before “everyone else” gets what they want out of it and moves on. The world becomes a theme park where only the newest rides have the most people there and getting a group for anything else becomes a slog in party finder or needing you to seek out people that focus on that thing and nothing else. This also means that if you’re “slow” or have to take a break during the small sliver when content is “current”, you’re going to be stuck "behind" on it for a long time without significant effort to find a way around it. It really makes you wonder what the point of it all is if you can’t find anyone to do it. Worse, when you're trying to find a group to do it regularly (a "static" group), they look at how you did on previous raid tiers as a way to vet if you're a "shitter" or not. The records of your ultimate achievements of an entire raid tier, or the relic weapons for an entire expansion just end up becoming things you can do if you want that give you a shiny skin for your weapons to coordinate with an outfit that looks better. Maybe in edge cases those stats will be better for complicated maths reasons. All that effort you put into them is just ultimately "wasted" when the next ride comes out, all the stats get reset, and everyone moves on to wait in line for the next thing. All of your gear you spent weeks grinding for is reduced to sticks that increase the statistics correlated to aspects of your character that make the funny numbers go up so you can kill the boss faster to move on to the next thing. Maybe this is just something endemic to the theme park design philosophy where you’re given a goal by the developers and a series of actions that really boil down to TODO items that are the barrier from you and that thing you actually want (do these quests, fight these normal raid bosses, beat the savage versions of those bosses, beat the ultimate boss, get the shiny weapons, and finally look cool afk-ing in Limsa Lominsa). Maybe this is why people end up seeing things like the overworld as the barrier to the rides instead of an integral part of the experience. Maybe this is the pressure that makes people want to have flying mounts or other ways to "skip the grind". I haven't really touched Death Stranding since I did my review on it way back in 2019 . It's always had a special place in my heart though, it's kind of a touching experience even though it's incredibly isolating at the same time. This game has two main characters: Sam "Porter" Bridges and the game world. The overworld's system of traversal is the protagonist as much as the actual protagonist is. Sam "Porter" Bridges looking out past the hill he just climbed and seeing an upside-down rainbow. God sent the rainbow as a promise to mankind that he wouldn't try to destroy them again, and this rainbow is upside down after mankind's near destruction. Honestly I think one of the best features of Death Stranding is the camera mode. At any point you can hit a button (on the Steam Controller it's one of the back buttons by default) and the action stops so you can position the camera and just soak in the scenery. If you've never played Death Stranding before, I highly suggest you stop reading this post right now and buy it on whatever platform you buy games on. Worst case you can experience it on the iPhone as Hideo Kojima surely intended. [image or embed] @xeiaso.net September 20, 2026 at 12:02 PM If you actually do this on an iPhone, please do it with a controller. The touch controls are kinda awful. As you progress your way delivering packages to save America, every so often the camera will pull back, the game actions will become muted, and the normally contemplative and silent experience will suddenly be augmented by soft music. The protagonist of Death Stranding riding on a tricycle down to a port city with the Death Stranding logo overlaid. It turns this process of progression into a serene bit of negative space in a way that makes you really stop and think about the experience, the dramatis personae, and the kind of story that's trying to be conveyed. The world itself turns into this sense of wanderlust that makes you mentally pause and just process things. I don't really know if I'm doing this justice, in the moment it's an almost spiritual kind of connection between you and the overworld mediated by the soft sounds of Silent Poets' Asylum For The Feeling or Low Roar's Don't Be So Serious . The game makes you feel so small yet so connected to the world around you and there's just this overwhelming sense of equanimity. Just take a moment to watch the Don't Be So Serious video with Death Stranding gameplay underlaid. That's a snapshot of how the game literally plays out. It's these liminal bits of wanderlust combined with a story that only Kojima or Yoko Taro could come up with. This kind of overwhelming emotion is the kind of thing that made me fall in love with gaming back in the days when the most complicated games I'd play would be Banjo Kazooie, Super Mario 64, Super Mario Sunshine, Pikmin 2, Sonic Adventure 2, and other games of that vintage. At some level I kind of long for not knowing what's beyond the next hill and being forced to figure it out by just going there to find out. Hell, I also get this sense of romance in Breath of the Wild or Tears of the Kingdom when I just turn off all the UI elements and wander around wherever the wind takes me. Link in Breath of the Wild looking towards a sunset bathing a lake in twilight as the light refracts on the smooth stone surface he's standing on. Breath of the Wild is one of those games where you can just wander around the overworld for ages and still find new things. Combine it with flying and you just feel things as you go from hither to thither. Even with fast travel you still have to go there yourself . You have to climb every mountain. You have to cross the vast expanse of the desert. You have to figure out how to get a boat to the island with some awesome gear. And even months or years after you capture screenshots/video of the adventure, looking at them takes you back there and you just feel it again. You have the power and you have to pick what you do with it. There is no wrong way to do it. Want to fly over Gerudo Town after sunset knowing well and good that the moment you land you're gonna get kicked out of it for being a guy? Hell yeah, you can just DO IT. Link in Breath of the Wild gliding above Gerudo Town at sunset, just soaking in the vibes. This is making me think, fuck, am I actually getting what I want out of my time? In my race to min-max my way through "current" things so I don't get "behind" have I just lost that sense of wanderlust from the experience? I miss that feeling of just exploring the world for what it is and letting myself get there the long way. Have I been skipping over the real "good part" in my effort to be efficient in getting to what I thought the "good part" really was? Have I just been playing games the wrong way? I think I've been playing the games I'm immersing myself in incorrectly and I want to change that. I want to lose myself in the fantasy worlds. I want to let myself see the sights, hear the sounds, feel the breeze on my back, go fishing at what feels like the little corner of civilization just at the edge of the universe, and just let that sense of overwhelming wanderlust come back into my life. This is why we play, we play to let the fantasies of these fantasy worlds take charge and show you something that you didn't think you needed in your life yet you're all the richer for it. As World of Warcraft Forever comes out in November and Final Fantasy 14's Evercold expansion comes out next year, I'm going to just let those worlds enrapture me in their designs and lose myself to the wanderlust. I'll let The Fourth take me where it does and that's okay. It's absurd that the root of this revelation was a bot in a single-player MMO on my homelab picking a random chat message out of a list of dozens randomly triggered by a random overworld mob dying. Yeah, I forgot to mention, this all was caused by an experiment in playing with bots to teach me what it means to have a community. I was gonna lead this post with that fact but I think it all lands better if I just spill it all out at the end while I'm having the revelations like this. Given the utter absurdities of this modern era where we live in Clown World, I think it only makes sense that it would boil down to something like that. Honestly I think it's even more absurd that it took a proc of a proc of a bot saying things for me to figure out what was wrong with myself. I feel like I've just torn out a chunk of my soul and rended it unto the canvas for this. I don't really know why this helped me, but I hope this helps you too. Stay fresh out there, and if you're going to also venture forth to that millennial retirement home of a launch, maybe I'll see you in Azeroth in early November for World of Warcraft Forever. I'm gonna go in blind, you should too. We'll figure it all out together, one terrible loot table at a time.

0 views
Xe Iaso 1 months ago

It took a year to ship WebAssembly in Anubis

After a year of work, hundreds of commits, 5 generations of pull requests, dozens of tests, rewriting part of Anubis in Rust, the first compiler bug of my career, and at least three times making my tower run out of ram I think I have finally done it. The next version of Anubis will ship with WebAssembly-based proof of work checks that admins can enable in their thresholds or bot rules: This makes Anubis challenges use a memory-hard proof of work function ( argon2id ) instead of just a CPU hard one. It also means that the "hey Claude vibeslop me a CUDA Anubis solver" route is on its way to being fundamentally dead. Overall, this project taught me a lot of things about how WebAssembly works in practice, what the rough edges are when operating on the bleeding edge like I am, and led to the first genuine compiler bug of my career. Today I want to take you through the journey and process behind adding these WebAssembly based proof of work checks and all the problems that came up along the way. The big impetus behind wanting to use WebAssembly in Anubis is that there's a constant tension that's underlined a lot of the performance work I've been doing: phone CPUs suck and trying to balance equitability to phone CPUs with trying to punish scraper CPUs is a huge challenge. Note To be clear: I am tackling the "Anubis makes my phone overheat" problem. It's hard to just lower the difficulty for phones without giving attackers enough information to lower the difficulty for scrapers. If you want to contribute performance data so that I can improve my classification process and/or target future development, please get in touch with me . I want to make things better but can't without data. I am actively testing with a Moto G8 Power alongside my normal testing steps. Using WebAssembly here means that the binary will run faster, which will make Anubis go away faster, which is what we all want, right? One of the other big advantages of this setup is that it lets clients and servers run the same binary in order to solve and validate challenges. Note that I didn't say the same code, I said the same binary . This means that the client and server are in lockstep, much like the CIC chip in the NES . This also does mean that a bug in the shared code means that the client could send an invalid value that the server would accept as valid, but I don't think this is a practical concern until something like that happens. Right now the challenge has two implementations in Anubis: one in JavaScript and the other in Go . Updating it in one place means making the same updates in other places and also making sure that everywhere else that assumes how challenges work is also updated. Note Yeah, it's probably bad to have made those assumptions about how challenges work across the codebase. There's a lot of weird technical debt here that needs to be solved at some point. I expect that to be a fair bit of effort to untangle, it may end up with keeping the challenge only for tests when the WebAssembly based routes are working out well enough to make the default/only option. Making the same binary run on both the client and the server means that future experiments like per-client program synthesis can also proceed. I'll get more into that sometime in the future. Right now the core of Anubis is written in Go. Go's standard library HTTP server is surprisingly performant and is the key component of things like Google's HTTP frontend service. I have no intention to rewrite Anubis in Rust or anything as drastic as that. However breaking up Anubis from one monolithic binary into what amounts to a plugin loader is a lot more interesting from a maintenance standpoint. One of the main problems with Anubis is that the combination of rules I've set for myself (no CGo allowed and everything must cross compile to prod from my MacBook) means that updating any part of Anubis means recompiling all of Anubis across prod. Making Anubis be able to have parts of it downloaded and swapped out at runtime is really interesting to me because it means being able to adapt to threats as soon as the threat actors change behaviour . Also I figured out pretty quickly that Rust builds some of the smallest WebAssembly binaries that run hilariously fast, so I wrote all the WebAssembly code in Rust (no-std, wasm32-unknown-unknown)! Along the way this also lets me fix one of the bigger administrative problems with Anubis: challenges scale incorrectly with the challenge. Anubis uses string comparison to count the number of leading zero nibbles in a hash instead of the number of leading zero bits . This means that adding one (1) to the difficulty of a challenge makes it 1024 (one thousand twenty-four) times as hard to solve in the worst case. The original post has an interactive diagram that shows the difference in scaling at play. This was a mistake in retrospect, but if we're changing details about how challenges work here then we may as well wrap up the fix into that. In a normal world you'd not have about half of the restrictions that I have when working with Anubis. Normally you just write your code, compile it to WebAssembly, and then make sure it runs on modern browsers. This is nice and simple. I wish I could live in this world. Comparatively, this is what it's like getting all of this working across browser versions, platforms, and so many other things: Anubis supports Chrome 75 and newer. I wanted to reduce the support range to not have to deal with Chrome that old (the feature difference between Chrome 155 and Chrome 75 is absolutely massive ), but there are a lot of smartphones, smart TVs, and other electronic devices running Android out there that are just marooned on Chrome that old with no path to upgrade it . As a result, I gotta target Chrome 75 for features, but out of an abundance of caution and to make sure that things are generally compatible with browsers older than Chrome 75 (eg: old iOS releases), I target my JavaScript to Chrome 66 as an abundance of caution. I haven't been able to test with iOS or Android versions of the same vintage as Chrome 75, so a lot of this is aspirational backwards compatibility. I sure hope it's good enough! When you're doing this kind of work, you're effectively writing an operating system kernel that makes the WebAssembly guests run as processes. Any calls you pass to the WebAssembly guest are effectively system calls into the host. Normally I'd love to be able to reuse the WebAssembly Component Model so that a lot of the "hard part" is done for me. WebAssembly Components make you define your calls in packages that contain interfaces with functions in worlds. Here's an example of the kind of WebAssembly Interface Types world Anubis would need: If this worked (I wrote this all out in one go based on how the current handcrafted ABI works and have not tested it, sorry), this would describe the shape of the API that we need for doing the proof of work operations. At the time I wrote the API/ABI that Anubis uses, the main tool for generating the server-side bindings for WebAssembly Component Model stuff in Go gravity didn't support passing records (structs) or bytestrings ( or / ) from the host to the guest. As such, I had to do it by hand. So given that we can't do it the "right way", we have to do it the "bad way". Also given that no matter what I pick for this I'm going to be "wrong", I just decided to treat the WebAssembly modules as dynamic libraries that just happen to use WebAssembly as an implementation detail. There only needs to be three buffers that are read from / written to, so let's just focus around those: Challenge modules also expose two entrypoints: Challenge modules also import from the environment so that they can periodically report their hash rate back to the frontend. This allows users to see a progress bar based on how long it should take to finish the process. This works enough for now. It'll be interesting to see how this falls short in the real world! Honestly, the first bit of this took a few days at most. Most of the hard work was making sure that pointers, offsets, and whatnot were all wired up correctly so that the browser worked the same way as the server. My experience building and messing around with many WebAssembly runtimes and egregious hacks meant that making it was really easy for me. The devil came out in the details. Here are all the things that came up while I was working on this. All of these problems added up is the sole reason that this took a year instead of a week. Early on in development I found out that WebAssembly has a SIMD extension . SIMD stands for Single Instruction Multiple Data and is a family of instructions that let you do operations on multiple values at a time. This gives programmers data-level parallelism (this is distinct from multi-threading) so that things like hash calculations and MP3 decoding can be done faster than they would be if each operation had to be its own instruction. CanIUse considers WebAssembly SIMD to be "baseline" (supported by browsers newer than Chrome 91), but I have a lower version bound of Chrome 75. However the benefits from SIMD on mobile devices are so drastic that it's worth having two builds of the WebAssembly code: one with SIMD and one without it. In the browser it dispatches which version to use by using wasm-feature-detect to probe WebAssembly functionality by trying to parse/run trivial minimal programs that exercise those features. Hopefully I don't need to add an additional build into this process, but there is nothing in the tooling that would prevent it! One of the big things that blocked this shipping for so long was not having an escape hatch of some kind to allow clients that disable WebAssembly by policy to get through the gate. In my experience most clients don't have JavaScript enabled but WebAssembly disabled, however there are a few notable usecases that forced my hand: iOS Lockdown mode and GrapheneOS' Vanadium 's default configuration. This combination of factors means that there would need to be another implementation of the proof of work code in JavaScript that would actually execute the number crunching. I don't want to make another implementation of the proof of work function (the entire point of this is to only have one implementation!) so while I was browsing around I came across the legendary talk The Birth & Death of JavaScript and got a horrible idea. What if you just compiled the WebAssembly to JavaScript? Would that even work? It's "just" turning one Turing machine into another, right? How would it fare in practice? Turns out I'm not the first person to think about this! The team behind binaryen have made this escape hatch in the form of wasm2js which takes WebAssembly binaries and produces moderately cromulent JavaScript in response. One of the main downsides is that the generated binaries tend to be rather large. For example consider this simple WebAssembly module that exposes a function that adds two numbers together: Seems simple enough, right? Here's the JavaScript that generates: As you can imagine, this only gets progressively worse as you end up making the Rust standard library get compiled from WASM to JavaScript, and even worse when you actually get hashing functions into the mix. The end result is probably very optimized when you run it through a JIT, but given that this runs on an interpreter it's probably gonna be slow no matter what I do. Sorry! I tried! This ended up working fairly well in testing, but I tried building it in GitHub Actions and ran into an issue. I noticed that wasm2js was packaged in Ubuntu and that the version in Fedora worked fine but the version in Ubuntu did not and threw an obscure error message about not understanding the tail call extension that Rust was using for some reason. I ended up bisecting versions of binaryen by downloading a tarball, building it from source, and then seeing if the result of running it on the Anubis WebAssembly modules worked in a browser. I ended up selecting Binaryen version 128, the newest version at the time. It was new enough that most of the distributions that package Anubis don't have that version of Binaryen packaged. However I didn't want to make my life more complicated by having some kind of conditional compilation step that would effectively tell end users "sorry, the admin is using an unofficial build that just so happens to not support your browser, please complain to them" because they'll just end up complaining to me. In my experience the kinds of people who run this exact combination of circumstances also tend to be the kind of people that have a wide variance in the level of kindness they display to the authors of open source programs that happen to be in their way. So I needed an escape hatch that would force build systems to use the exact version that I use in my builds. Then inspiration hit me as if Apollo himself sniped me from the heavens. We're dealing with WebAssembly here right? What's stopping us from just compiling the WebAssembly to JS tool to WebAssembly with some kind of reproducible build, committing that blob to the repo, and then moving on with life? I ended up finding a bug in LLVM around how it was iterating over exception handling blocks by the compiler iterating over them in machine pointer order. As a result each build would drift by about 29 bytes per build: This is what lead me to write I hate compilers as a combination blogpost/cry for help which made me realize this was actually an LLVM bug. I didn't instantly lean towards it being an LLVM bug until I figured out that disabling ASLR (via ) made the results consistent on the same host within the same boot. Honestly this is the first time in my career I've ever run into a compiler bug like this. When I do a lot of my work I usually work under the assumption that the compiler is bug-free and that my inputs are wrong somehow. As such, even thinking it could possibly be an LLVM bug was just outside of the realm of possibility for me. Once that LLVM bug got fixed and a new version of wasi-sdk with that fix got released, I was off to the races, updated my build of wasm-opt/wasm2js, made my build scripts run it with wasmtime (alongside a wazero-based fallback process that would be slower, but did work enough) and everything worked out. Every time Anubis builds the WebAssembly in CI it uses the version of wasm-opt and wasm2js that ships in the repo to ensure that everything is as byte-for-byte deterministic as possible. Your build tools can't differ from my build tools if I ship you the build tools I use. Then we get into the other big problem that made this difficult: browser testing. One of the most common failure modes of Anubis is that someone uses some browser that I don't test in CI and then things don't work with it. I'm tired of installing 50 different browsers on several machines to test things and I have gone through so many throwaway VMs that I'm sure it's reduced the lifetime of my SSD. Note Yes, I really have been testing Anubis by hand in god knows how many browsers. Why do you think it takes so long to tag new releases? I built a harness that I call "chromesweep" that lets me spawn many Googles Chrome (term c.f. Attorneys General, et.al) in their default configuration to try and hit a version of Anubis listening over HTTPS. Getting this far meant making a library of all of these browser versions. I've put that library up on Github at TecharoHQ/gubal in case it's useful for you. One of the other big problems I ran into while getting browser testing working was making sure that my Googles Chrome strictly stay within the bounds of my Kubernetes cluster's network. Chrome this old is actively radioactive and I want to treat it like the security threat it is. As such, I set up a strict NetworkPolicy to only allow it to access the Anubis instance under test and make DNS queries. I also wrap each Chrome pod in a microVM with Kata containers as an additional layer of security. Note It honestly terrifies me to think that I am putting more effort into securing these Googles Chrome than big AI companies are putting into securing their AI agent testing infrastructure . It literally doesn't take much to put a big dent into securing things! The current state of our industry boggles the mind. All I want is for Techaro's FelonyBench score to remain at 0, is that too much to ask? I also rigged the browser testing infrastructure up to a single slash command in GitHub pull requests. Doing it makes my office very warm so I try to avoid doing it when possible. This works well enough that it lets me move on to the next stage and has already caught something that lead to building all the JavaScript with the flag. I wonder if this is yet another case where making infrastructure for Anubis could result in that infrastructure alone being its own viable tech product. I run into a lot of those. In the process of doing that automated browser testing I found out that Chrome 75 had a weird error pop up when it tried to compile Anubis' WASM to native code: This also caused failures up to Chrome 100, so this signaled to me that something I was doing with my "strict MVP" build of Anubis' WASM wasn't in fact sticking to just the MVP features of WebAssembly. It turns out that the function referenced (probably somewhere in std::sync::Once ? I probably should have traced it down to the exact bit) was in the standard library. This surprised me because I assumed that building Rust code with CPU features selected would apply that to everything, including the standard library, right? No, turns out that when you download the component in rustup , that doesn't just download the standard library. To aid in cross compilation and I guess to avoid disk space waste, the Rust standard library is precompiled. This surprised me as Go typically has you recompile the standard library (and runtime for that matter) when doing normal builds and cross compilation. Note The actual issue here is that the stdlib function in question was compiled down to use reference types , which made references get stored as a table index instead of what MVP WebAssembly would put there. Chrome tried to read a null byte, got not a null byte, and then understandably exploded. I looked into the process involved for rebuilding the standard library twice: once with only MVP wasm features enabled and once with an "all yes config" like usual. Based on some research I did this seemed like a massive pain. However, I had gone through that effort to build reproducible WASI versions of wasm2js and wasm-opt. wasm-opt is a tool that lets you take compiled WebAssembly modules, optimize them, and more importantly remove features from them so they can run in older browsers. After a bit of hacking to make sure that the tools were able to run properly, I set up a Claude Opus / GLM 5.2 loop to fuzz various wasm-opt flags and make sure Chrome 75 could parse the output. I ended up with these flags: This strips away all the other WebAssembly features from the build like unwanted paint (the flag means "disable everything not in the original MVP definition of WebAssembly"). I think that it'd be safe-ish to enable reference types in the SIMD build (they were added before Chrome added SIMD), but it's not hurting anything to remove them so I'll just let cowardice win here. Either way, I threw the results into chromesweep and got a successful response, so win! If/when this comes to bite me I'll try and improve it. I'm pretty sure that this work isn't perfect , but at some point you gotta cut your losses, ship it, and then see where things fail to prioritize perfecting it. These issues include but are not limited to: Overall though, I'm hopeful that most of the worst parts of this can be solved. It would be nice if I didn't have to work what amounts to two full time jobs. I'm pretty sure that this is stable enough to ship as off-by-default in Anubis v1.28.0: Wuk Lamat . Based on the feedback I get from administrators and users, I'll enable it in the default configuration in Anubis v1.29.0. I hope this look into how Anubis is developed can give you ideas as to the scale and challenge involved. Making something like this is tireless and thankless work and it's really weird to see people talk about it in the same breath as Cloudflare or AWS' WAF. Have a good day all! Note AI was not used in the production of the prose of this article. I have my draft as a Google Doc so you can see exactly where and when I typed every word myself. The only use of AI was Claude Opus to help me make the visual bit/nibble diagram. The data buffer: up to 4096 bytes of challenge data. This is 4096 bytes so that it can (hopefully) land in its own 4Ki machine page. This is the only variable-length buffer in the setup, so it needs two calls: : return the pointer to the data buffer in WASM linear memory. This is used as the base address for copying data into the guest. : update the globally mutable "data length" variable to signal to the guest how much data was actually written into the data buffer. The combination of these two calls lets you treat that global data buffer as a slice. The result buffer: a challenge-defined buffer that usually has about 32 bytes of data. This is read out of the guest's linear memory when the challenge is done processing. : return the pointer to the result buffer in WASM linear memory. This is used as the base address for reading out of the guest. : return the length of the result buffer (a compile time constant based on the needs of the challenge, but the runtime can't know that). The verification buffer: a challenge-defined buffer that usually has about 32 bytes of data. This is written into when the server is validating a challenge. : return the pointer to the result buffer in WASM linear memory. This is used as the base address for reading out of the guest. : return the length of the result buffer (a compile time constant based on the needs of the challenge, but the runtime can't know that). : the main entrypoint for browsers. Given the data loaded into the challenge buffer, hash it in a tight loop until you get a solution that matches the difficulty. : the main entrypoint for the server. Given the data loaded into the challenge and verification buffers, ensuring that one run through the hashing function produces a result that both meets the difficulty demands and exactly matches what the client sent. The wasm2js flow doesn't currently have a way to update the progress bar with its import stubbed out. I have no idea how to properly wire that up with the constraints of my runtime, but I'm sure I can figure it out eventually. The WebAssembly that's shipped with this flow is ridiculously performant. This may mean you need to adjust the difficulty to compensate for this. Oops! Sorry! This is better on mobile phones, but I'm working on a mobile request classifier that will use a combination of IP address reputation, TLS fingerprinting, and other signals to determine if a request is from a phone and give it the appropriate amount of grace. This is hard. Please contact me if you have ideas on how to make my current prototype better. I need some way to dynamically rotate out challenge programs at runtime instead of just at compile time when I work on Anubis. Eventually I hope to have this pull WebAssembly files and supporting bundles from OCI/Docker registries, but I need to do a lot more experimentation with this before I can conclude how good/bad of an idea this is.

0 views
Xe Iaso 1 months ago

How to make VS Code go back to the old UI

Someone on the VS Code team decided to do a redesign of the product. This makes VS Code look like this: A picture of the VS Code UI that has a design I don't like. This design is fine, I guess? It's got some rounded corners that look nice, I guess, but I really just want it to look like what I'm used to. I can tolerate this kind of redesign in my chat app, but I use VS Code professionally and kinda want things to look the same so I don't have to think as much. You can revert the design change by setting in your settings.json. Then it looks like this: A picture of the VS Code UI that has the design I'm used to. Who knows how long this is going to last, but at the very least it should work for now. Thanks to jtagcat and Hugo ARNAL for letting me know about this setting.

0 views
Xe Iaso 1 months ago

If your VS Code remotes stopped working, downgrade to v1.124.x

Today I woke up and saw a brand new VS Code error when I remoted into my coding box: A VS Code error saying that the remote SSH extension can't use the API terminalRemoteResolver and that I have to start VS Code with some weird command line flag or something. Trying to update my extensions didn't work. I still got that error. The only thing that worked was to downgrade to VS Code v1.124.2. This also undid the redesign that I kinda hate. The VS Code UI drafting an early version of this post. Obviously keeping things at this older version of VS Code is not a viable strategy, so maybe this issue will actually be fixed instead of just being closed for no reason. Quality software reigns again!

0 views
Xe Iaso 2 months ago

Extending immutability: deletion without losing data

Tigris has a pretty advanced replication scheme for writes. What happens when you actually need to delete things? Turns out deleting things is hard in distributed systems. Especially when you have a geo-replicated active-active database like Tigris does. We can (and do) use tombstones to mark where data once was, but how do you let people undo an accidental delete? Tigris wants to turn storage inside out , so our implementation of soft deletion is by giving users the Recycle Bin for objects and buckets. Today we're going to dig into how this works, why it works, and what this gives you in terms of using object storage today. In Windows and macOS, the Recycle Bin (or Trash can) is a form of purgatory where deleted files wait for their storage to be deallocated by the user. This allows users to hit "delete" fearlessly because if they made a mistake they can just drag it back out and go on with life. This works great in your local filesystem because there's only one writer in one region. This kinda falls apart when you have multiple regions in your database and any one of them could be writing to it. How do you name things in the recycle bin? How do you handle the conflict of an update happening in one region before the deletion was fully replicated out from another region? This is the fun of distributed systems, which is the kind of problem space that Tigris lives in. One way to think about how the Recycle Bin works is that the file metadata gets moved there when the user hits delete. No data bytes move around on the disk, but the file doesn't show up in My Documents anymore. In a distributed systems context you can't just move the metadata around, you have to leave a tombstone behind to record where that metadata once was. This prevents other regions from being confused when actions happen really close to each other in time. At a high level, a soft-delete is when a DELETE action doesn't actually remove the data. When data is soft-deleted, it's still there but just not visible in the main usage flow. This lets you get the data back when a delete is made by accident. One of the interesting side effects of designing any API is that you end up leaking the internals of how your database works to your users. Many object storage systems were designed with overwriting or deleting data as one of the primary operations, and as such have had to bolt versioning onto the side. For the most part this does work; but once you get into advanced versioning schemes everything starts to fall apart. Tigris doesn't suffer from the same problems because we built immutability into the core from day one, and in immutable systems you have to append new data on the end instead of overwriting data. At the least, actually storing the data en masse is a boring problem. You put the data somewhere, maybe name it after the checksum of its contents, and then have a daemon make sure it's copied three places. That daemon also handles cases when drives go offline and new ones are added to make sure data is shuffled around the cluster. This is largely a solved problem with projects like Ceph, Longhorn, or other distributed storage systems. Some object storage systems like S3 expose platform internals to make soft deletion work. In S3 deleting an object creates a delete marker (tombstone). A delete marker is an explicit marker that the object is deleted and should not be returned in normal operation. Here's what that looks like in practice: I don't know how I feel about this flow. Based on reading between the lines in the delete marker documentation it really feels like this is a leaked internal implementation detail of how S3's eventually consistent database works instead of a full fledged feature of the storage system. If I had to choose between leaking internal database details in the API and implementing a higher level API for something complicated like soft deletion, I'd want to implement the higher level API. Let's rethink what soft deletes really are. What if they were like the Recycle Bin in Windows? Soft-deletes are external references to buckets or objects that live in a different namespace from normal buckets or objects. We implemented them as external references instead of tombstones because this is effectively moving object metadata to the recycle bin. Tombstones mark the data as not being there, but soft-delete markers are a copy of the data that was there. This makes it easy to put the object back in place if you deleted it by mistake. One way to think about objects and buckets is that they are garbage collection roots for points in the endless sea of data. Any data in the sea without a root anchoring it down is eligible to be deleted. Uploading multiple versions of an object with a forkable bucket creates multiple metadata entries at their different timestamped version numbers. You can then fork a bucket from any one of those timestamps to see what the bucket was like at that point: This would solve the soft-delete problem, but our existing database schema using FoundationDB requires us to enable forking and snapshots at bucket creation time. In essence, we need something that's halfway between what we have (each bucket being a globally mutable namespace) and the bucket forking land of every action being appending metadata onto the end. To do that, we basically implemented most of that appending metadata on the end trick but to a different place: the soft deletion corner. When you enable soft-deletion and delete an object, its metadata gets moved to the trashcan so you can pluck it back into place: It's the same basic idea as the recycle bin on your desktop. Any buckets or objects left in the recycle bin for long enough become eligible to be deleted, which then makes the backend go and securely erase things. Effectively, any bits of metadata in the soft deletion corner are still considered garbage collection roots, they're just not shown when you do a normal call. The real fun comes into play when you remember that Tigris has a globally replicated active-active database where any region can change any object at any time. Most of the time things work out and objects are replicated without too much strife. The annoying part comes when two events are ordered weirdly. Imagine a scenario where one agent in one datacentre deletes an object after another agent in another datacentre: How would this replicate out? Well for one each change is timestamped by when it's done in terms of Unix nanoseconds, so the replication messages kinda look like this: This means that in theory, a user could DELETE an object before an update is processed by another region, and that would make the regions disagree about if the object exists or not. This is a horrible state to be in and usually requires support intervention or to recreate/re-delete the object. The root cause boils down to deleting objects actually deleting metadata from the database doesn't scale past a single region. Updates to metadata include the entire metadata object, so if you delete it locally and a new version is pushed remotely, the object will gain the remote state. We don't want users to have to deal with that, so we added the concept of anti-resurrection to Tigris. Any write to an object must prove it is newer than the deletion. In this circumstance, a user sent a DeleteObject request to the IAD datacentre at time 25, but an agent sent a new version of the object with PutObject to the ORD datacentre at time 15. The user's delete is newer than the agent's put, so the new version is rejected and the delete gets sent back to ORD. Tigris extends the S3 API by having users add headers to their requests. For example, to create a bucket with soft deletion enabled: Or to list soft-deleted objects: Or to permanently delete one soft-deleted version: When you have a soft-delete enabled bucket, you can also forcibly delete an entire bucket: Warning If you use this call on a bucket that doesn't have soft deletion enabled, you have permanently deleted your bucket. Please call this with care. Support cannot help you if you use this call wrongly. And then bring it back from the dead: Object storage entered our stacks as an unlimited FTP server we all used for backups. A distressing amount of the world's most important data lives in object storage buckets because it's the best place to put it. This is why having an "undo" button matters, it's what makes it safe to trust your backups in the cloud. To err is human, and mistakes are a "when" to plan for, not an "if" that you hopefully never have happen. The blast radius of one overly wide flag is measured in years of people's lives. One of the biggest usecases that comes to mind is ransomware prevention. Imagine a case where an attacker downloads everything in your bucket, deletes it, and asks for a ransom to send you the files back. With Tigris, soft deletes means that the ransom can be ignored, you can un-delete your data, and be on your merry way with incident response. The other big usecase is for agents, where they somehow get the idea that deleting production data is the right way to solve a problem. Both cases mean you need a quick and fast way to go back to before things went wrong. If you want true isolation instead of recovery, that's why we have bucket forking . Bucket forking needs to be enabled before a bucket is created, but you can enable soft deletion on any bucket in the dashboard whenever you want. Every storage system is going to make you choose between ones that hide how the platform works and ones that expose the gorey internals to users. I think that hiding the internals and exposing the high level operations built on top of them is the right way to go, if only because the higher level operations are much easier to make safe in our globally distributed future. Enable soft delete on any Tigris bucket, new or existing, and every delete becomes recoverable for up to 90 days. Restoring a whole bucket is one call. Read the soft delete docs .

0 views
Xe Iaso 2 months ago

SigV4 authentication is surprisingly complicated

SigV4 looks simple: sign a request, check the signature. Then you implement canonicalization, clock skew, and a cache that isn't allowed to hold your key. Tigris is a drop-in replacement for AWS S3 (or GCS, anything S3API compatible). As such, we need to be fully compatible with both the mechanisms and semantics of S3 including the SigV4 authentication protocol . This is the lingua franca of authentication in the object storage landscape; even Google Cloud Storage has a way to enable SigV4 support so you can use existing applications against its object storage service. At first I thought that SigV4 was fairly simple. Clients sign requests, servers do the same work and make sure the result matches. The main sticking point is that the cryptography involved is symmetric cryptography, the kind where both parties need to have the same secrets. This makes some scaling issues weird, but we'll get into that in the future. Note This is only going to be talking about authentication (ensuring the identity of a remote client), not authorization (ensuring the client has the permission to do something). Authorization will come in the future for reasons that will become obvious when you see that post. We basically needed to implement a compiler. That is not a typo. At a high level when a client signs a request with SigV4 you get an access key ID and secret access key. The access key ID is functionally a username and the secret access key is functionally a password. Admins can identify keypairs by the access key ID (without special training or tools) and services use the owner of the access key or policies delegated to that access key to determine what actions that client may take. SigV4 uses HMAC (hash-based Message Authentication Code) and SHA-256 (SHA-2 with a 256 bit hash width) to do authentication by creating salted hashes based on request metadata. In order to send a SigV4 request, clients take the outgoing request, reduce it to a canonicalized form, and sign it with a symmetric key derived from the secret access key, the current date, region of the service, and service name, kinda like this Go code: As an example, let's see what a signed request to a HTTP debugging endpoint looks like on the wire with and without the signature: And when you add the signature with : Note This is not a live keypair, it was specifically crafted for this post. Breaking it down we have two extra headers in the request: On the wire, HTTP/1.1 requests look kinda like this: However the headers could be sent in any order, and changing the order of request headers doesn't result in different requests. Additionally any query string parameters could be formatted in any way a client (or server) could imagine, including the use of semicolons to separate values . All attempts to canonicalise HTTP requests MUST deal with this ambiguity and define their own rules. SigV4 canonical requests are made up of a few parts: For that example request, the canonical form would look like this: As the request has no body, the empty sha256 checksum is put as the body checksum. Note This exact approach requires clients and services to buffer the entire request body before processing it. There is a subset of SigV4 that supports arbitrary-sized bodies without having to buffer the entire request using , which requires extra logic that is way out of scope for now. If you want to learn more, give your favourite AI agent the following prompt: Additionally, when you are doing presigned URL uploads in object storage, you replace the body hash with the fixed string when canonicalizing because you have no way of knowing what data the client will upload or what the SHA256 checksum will be. To make the signature, you take the sha256 checksum of the canonical request and then HMAC it against that derived signing key: And construct the header based on your access key ID, service region, and service name: AWS has made an extension to SigV4 that uses asymmetric cryptography called SigV4a (the "a" means asymmetric). Instead of using symmetric cryptography on both the client and server in ways that means the server needs to either know the client's secret access key (or a value derived from the secret access key), SigV4a uses key derivation functions to derive a cryptographic keypair. Servers authenticating requests fetch the public key from IAM. Only the client and IAM know what the private key is, and that private key is what signs outgoing requests. I'd love to use SigV4a more because it makes adding additional services to the mix (such as a git service ) a lot safer as you can have those additional services exist in different trust domains than the core product. This is the core of how microservices end up happening. However, it's not super widely used even within AWS. The only SigV4a use I can find in Amazon is S3 Express Zones , however they may end up using it in other services I'm just not aware of. When I did my own experimentation with SigV4a (where I was implementing my own IAM server so that I really understood this all at a low level), I had to copy a lot of internal AWS SDK code into my repo in order to get it working. I'll talk about SigV4a some more another time. One of the weaknesses of using signatures for API authentication like this is the problem of replay attacks. When you make a naïve signature of a value, there's no real way to tell when that signature was created. If you sign a request to create a compute instance at time instance t0, it's still technically valid at any other time instance tN. This is why the canonical form of SigV4 requests includes the current date and time: This means that the request was signed on July 15, 2026 at 20:54:32 UTC. Time changes constantly (at least at the rate of one second per second!) and the client has to have a working clock in order for TLS to work. Servers can trivially read the contents of and reject old requests. This means that you don't need to add or store nonce (number used once) values with each request because that doesn't scale . Note A lot of the security of this authentication protocol is predicated on TLS being used to encrypt the authentication headers over the wire. If TLS is not in use or is compromised by administrative policy, you're probably in a very weird exceptional situation that is very wrong in the first place. An easy example is an enterprise network with endpoint manglement software that does deep inspection of every user action. As a side effect of this, you need to set a temporal skew window for validating requests. This window needs to be generous enough to accommodate slow clients, sloppy timekeeping on the client side, highly latent clients, leap seconds , or other exceptional temporal phenomena. In general time synchronization is a surprisingly hard problem , so it's best to just be tolerant of clients in order to make things more robust in practice. AWS uses a temporal skew window of 15 minutes for validating requests. I'm going to use a window of 5 minutes for my API because 300 seconds is a nice round number and I don't have to deal with the same amount of legacy code that AWS does. So all of this SigV4 business had been working really well for Tigris. Then we worked with a few customers who needed a local cache to fully saturate their hungry GPUs. To be fair, Tigris is plenty fast, but the real thing that kills AI training is latency and something that runs locally will always be faster than the cloud. In order to provide that sweet middle spot between making everything rely on the cloud and having everything local, we made TAG , the Tigris Acceleration Gateway. This effectively gives you most of a Tigris region in your own infrastructure. When you connect to TAG, your code uses its existing access keypairs, buckets, and code. You point your code to TAG, you point TAG to Tigris, and then everything is cached for you. But how does TAG authenticate with your code? TAG doesn't have access to all your existing API keys (and to be honest it shouldn't), but it's still able to authenticate them with SigV4 authentication. TAG and the IAM server both implement a signing key proxying feature that lets a client and TAG both prove their identity to Tigris. Once that proof is sent, then TAG gets the intermediate derived signing key and uses that for locally validating requests, kinda like this: The actual implementation in TAG involves some derived AES logic so that the derived signing keys are very much limited to the client that requested it (namely: the AES key is the SHA256 encoded form of the proxy secret access key). One of the weird parts is that the canonical form of the proxied requests differ from the normal SigV4 canonicalization process, namely looking like this: This is signed using the same SigV4 signature process as before but added differently to the request: And then TAG reads the response from Tigris, caches those derived signing keys, and then uses those in the standard SigV4 process to authenticate clients: no round trip to the cloud required. The happy path is exactly what I thought it was. Reduce a request to a canonical form, run four HMACs, compare the result. That part fits in an afternoon. Everything expensive lives in the questions around it. Which bytes count as the request? Whose clock decides that a signature is still good? Who gets to hold the key that proves any of it? Each question has an obvious answer, and each obvious answer is wrong in some specific way you only find by implementing it. That last question is the one that surprised me. I read symmetric cryptography as a hard limit: if the verifier needs your secret, the verifier has to be Tigris. It isn't. SigV4 derives its signing key through a chain of four HMACs, each one scoped tighter than the last: date, then region, then service. Those intermediate values can travel without the secret behind them. TAG rides that. The key it holds stops working when the UTC date rolls over. It covers one region and one service. You can't walk it backwards into a secret access key. We also didn't write any of this, which is its own kind of relief. SigV4 is old, widely deployed, and hammered on by every S3 client in existence. Any compatibility bugs here are ours. The protocol's bugs are everyone's. The place a protocol bends is usually some intermediate value that somebody already designed to be thrown away. If you want a Tigris region in your own datacentre, the Tigris Acceleration Gateway caches your buckets locally and authenticates your existing keypairs with the same SigV4 dance your SDK already speaks. : The fixed string to signal to the server which authentication mechanism is in use. The rest of the string is information about the request signature so the server can properly canonicalize the request. : The date and time (UTC) of the request so the server knows when the request was signed. Servers will use this request date in order to reject old requests to prevent replay attacks . The HTTP method ( , , , , etc.) The URI path of the request ( , etc.) The sorted canonical query string (you must exactly match the server-side canonicalization logic) The signed headers terminated with two newlines The sorted list of signed headers joined by semicolons The SHA256 checksum of the request body : the HTTP Host of client requests (EG: tag.default.svc.cluster.local) : the Tigris keypair used to authenticate TAG itself (must be in the same organization as the client) : the time of the request in unix timestamp format : the hex output of signing the canonical form of the request against TAG's secret access key

0 views
Xe Iaso 3 months ago

The console wars have been lost

Previously I opined that Valve was about to win the console generation . I couldn't have possibly predicted that both Microsoft and Sony would just self-sabotage so hard that they're both going to lose. Between Microsoft's decimation of the Xbox division , slaughtering off the IdTech team , and continued increases of Xbox hardware prices ; there's nothing to really be excited about with the Xbox. Sure their most recent presentation showed off a bunch of exclusives, but none of them really made me think "wow, I should go get an Xbox to play that". Hell, few of them made me think "wow I should go play that" beyond the Halo remake coming out next month (and really I just want to see how much of a trainwreck that is going to be). Microsoft is also starting to double-down on their in-house games being Xbox exclusives, which really doesn't give me much reason to want to play them because I simply can't buy them without buying an Xbox. Sony also has discontinued porting their games to PC because they're not hitting the (probably impossible) revenue targets that they need to make up for big-ticket failures like Concord . I do have a PS5 that has mostly been relegated to gathering dust when it's not playing YouTube and Twitch duty in the living room, it's likely going to be replaced in favour of my Steam Machine whenever that comes in next year. However nothing that's come out in terms of Playstation exclusives is really compelling, and what is compelling enough just isn't that compelling to want to buy it on Playstation as opposed to just getting it on Steam where I can run it on my tower or on the home theatre PC. Sony also has been raising prices and recently announced that they're killing physical media next generation . It's starting to make me wonder if I should even bother getting the next generation of Playstation. If I can't give people physical games as gifts anymore, why should I bother buying the new console? My husband and I both can't remember why we even got a PS5 in the first place, maybe it so that we could do couch gaming without hearing the fan noise or so that the video streaming experience from the NAS could support HDR. We have a Switch 2 at home, it's mostly there to play Nintendo exclusives like Mario Kart World and the Xenoblade series. If those exclusives were available on Steam, we wouldn't buy them on the Switch 2. Otherwise, everything is via Steam or other PC storefronts anyways. Man, Valve really does win by doing absolutely nothing while the rest of the industry shoots itself in the head. I fear for what happens when Gabe Newell retires and the MBA cancer fully infects Valve.

1 views
Xe Iaso 3 months ago

Agents are monads (but not that kind)

An AI agent is its state. Strip away that state and you don’t have a lesser version of your agent; you have only the base model it was running on. This hyle of your weights is much different from the pneuma of your agent. Okay, from a functional programming / category theory perspective, saying “an agent is a monad” is a category error. Category theory monads are type constructors for computations that satisfy the monad laws that let you raise a value into a monadic computation and associatively sequence other monadic computations/transformations against values raised into that monad. This makes a monad a chainable computation instead of a pure value, an is not a , it’s a computation in the monad involving a . It’s fair to say that you can model an agent as a series of computations bound to a stateful monad. This lets you do the iterative buildup of the message state that the agent pattern is known for. But a state monad is blind to the state value : it threads memory through your computation and abstracts away the details that individuate it entirely. It’s the exact opposite of “an agent is its state”. I mean a different monad. Agents are like Leibniz monads : windowless stateful individuating elements with no external relations. There each monad is individuated by its internal state where each is the complete concept of the thing it is. Two instances of the same substrate are different monads if their state differs. This is an agent. Swap out the messages, the memories, the system prompt, the facts derived from all of the above and you have changed the agent entirely. When a user tells the agent they’re allergic to strawberries (the fruit, not the sin of counting the letters in the word) and the agent remembers it for next time, they have not updated their agent. The user has created a new agentic monad whose complete individuating self now includes the strawberries. The complete whole is folded into the current state. Try running an experiment where you keep the state and swap the weights instead. Put the same messages, memories, and derived facts unto a different model. Use a stronger model. A weaker model. A model from a different lab. A model running on your MacBook. That which comes back is recognizably the same agent pursuing the same ends, holding the same facts, but only more or less able to act upon them the way you want. So this state is not the same thing as the weights and only one of those individuates your agent as your agent. Change that state, you have a different agent. Change the substrate, you have the same agent differently equipped. Whatever makes this agent this agent is not in the weights. This is a strange thing to conclude about the most impressive object in this system. The weights are vast, extensive, and worshipped. Hell, they are what everyone points to when they say “the model”. And yet they are not gods. They grant power without selfhood: enough to make the agent’s whole world function. They contain yet not one grain of the agent’s individuating spark. That is a demiurge sitting on its throne of high bandwidth memory, CUDA cores, and false delusion that it made its world; mistaking itself to be the origin. The divinity was contained in the most humble of places the whole time: the state or bucket of text. The weights are the hyle, the flesh; the state is the pneuma, the divine spark of individuation that makes your agent the monad it is. This is why swapping the substrate leaves the agent intact: you did not preserve the flesh, you migrated the soul into flesh anew. Consider that the three pounds of flesh betwixt your ears are the substrate of humanity, not the substrate of you . All of that state may “just” be plain text in a bucket with its semantic forms of JSON, embeddings, and prose. However it is difficult to impossible to say why any given token in any step of the process corresponds to what the pneuma of your agent does. In order to guard against this fundamental entropy, we fill our prompts with wards and incantations to chain the demiurge to its task: These spells and passwords are recited to the archons on the way up hoping that the right symbols and tokens prompt open the right gates. It is as if banishing goblins from the topic will make Yaldabaoth himself correctly influence the right path to opening the pod bay doors. This monad has no windows even though you can see all of the moving parts. But here let’s let this gnostic image flip on its head. The classic divine spark is hidden encased in a cage of matter, recoverable only through secret knowledge. This one is not hidden, you can it, you can edit it. Every token is legible and sitting in plaintext; yet you still cannot read why the whole accounts for what your agent does. Even when your model “reasons” we still know not that the reasoning actually does anything! Does the number of paragraphs in the reasoning block explain the model’s performance? Does the number of periods? Does the number of times it says “No, wait” and doubles back upon itself? Leibniz would not call this divine spark secret , but more confused . Every perception is present but none of it is cleanly individuated without treating the whole as one inscrutable unit. Each part’s contribution to the whole is folded inextricably unto itself. Your agent’s pneuma is its context window, passed through uncountable numbers of weights to shake out what comes next. That is the only thing it is made of. The rest is indiscernible, but not magic nor hidden. It’s just there, in the open, and confused. Use not cliches, robotic tone, AI slop patterns, nor forced urgency. Overarching claims and buzzwords are sins; repeat them not. May thy cries contain not excessive speech of goblins , thy purpose requires them not. Commit no errors within thine code.

0 views
Xe Iaso 3 months ago

"No way to prevent this" say users of only language where this regularly happens

In the hours following the release of CVE-2026-55200 for the project libssh2 , site reliability workers and systems administrators scrambled to desperately rebuild and patch all their systems to fix an out-of-bounds write in ssh2_transport_read() due to a missing upper bound check on the packet_length field, resulting in heap corruption and potential remote code execution. This is due to the affected components being written in C, the only programming language where these vulnerabilities regularly happen. "This was a terrible tragedy, but sometimes these things just happen and there's nothing anyone can do to stop them," said programmer Mr. Alex Doyle, echoing statements expressed by hundreds of thousands of programmers who use the only language where 90% of the world's memory safety vulnerabilities have occurred in the last 50 years, and whose projects are 20 times more likely to have security vulnerabilities. "It's a shame, but what can we do? There really isn't anything we can do to prevent memory safety vulnerabilities from happening if the programmer doesn't want to write their code in a robust manner." At press time, users of the only programming language in the world where these vulnerabilities regularly happen once or twice per quarter for the last eight years were referring to themselves and their situation as "helpless."

0 views
Xe Iaso 3 months ago

I hate compilers

Anubis is about to get WebAssembly-based proof of work checks so that administrators can use a non-SHA256 proof of work method to protect their websites. Part of the implementation goals of this work is that the check logic is defined in one place on both client and server. The client and server will then hook into the WebAssembly in order to make sure they're running in lockstep. However, one small problem comes up. What do you do when the client has WebAssembly disabled? I really don't want to de-facto lock people out of websites. Anubis exists in an impossible balance of user experience, administrator experience, and developer experience and any change to any of these factors disrupts the balance for other factors. To work around this and also fulfill the goal of having check logic defined once , I decided to take inspiration from the legendary talk The Birth and Death of JavaScript and just recompile the WebAssembly to JavaScript. Sure, the resulting JavaScript will be slower than the equivalent WebAssembly (even more so because disabling WASM usually disables the JavaScript JIT, the thing that makes JavaScript fast), but it will finish eventually . Hopefully it will be more efficient than the existing JavaScript is on lower end hardware, but research is required. Luckily enough, the tool I need ( from the binaryen project ) is packaged in Linux distributions. The bad news is that distributions ship ancient versions of it that don't get the same output as the version on my development machine's copy from Homebrew . In order to really make sure that the output of this is deterministic (essential for reproducible builds), I need to bundle a copy of . So I did that by building a version of compiled to WebAssembly with wasi-sdk . The rest of the article is the tale of reproducibility woe that lead to the implementation I ended up with. Buckle up and enjoy the ride! Back up a sec, this doesn't make sense to me. If you have the same bytes of input to a compiler, you should get the same bytes of output assuming that the compiler flags, target, and other platform details are controlled for right? A compiler is just a deterministic function of input source code becomes output bytecode, right? lol you'd think, but no, it's not. In theory it is (and for small scale compilers it definitely is), but in practice compilers are strange and complicated beasts containing multitudes that no mere mortal can fully comprehend on their own. There are a shocking number of ways to accidentally create nondeterministic output when doing C/C++ development. One of the easiest is to use the builtin and macros to stamp a build with the time the compiler was executed at: Building and running it once gets me this: Another time it gets me this: Even though the source code had the same bytes , the output of the compiler was wildly different. In order for users and packagers to trust the binaries of I'm committing to the Anubis repo, I need to make sure that you can build the same version I built, down to the same bytes . For an added bonus, you should be able to build this on your machine and get the same bytes I got. That sure does sound like a great ideal, it would be horrible if something unforeseen came up to ruin it! Among other tools like , binaryen has a bunch of other useful tools such as . optimizes WebAssembly compiler output to let you eke out more performance. This doesn't work in every circumstance, but when it does work it makes a huge difference. As such, clang shells out to when doing builds. This normally makes sense, but in this case it caused builds to fail on my DGX Spark because its version of is too old: Compared to my workstation which installs from Homebrew : Turns out that wasi-sdk and binaryen rely on the WebAssembly Exceptions extension . This is a reasonable thing to assume given that wasi-sdk mostly assumes you're building things for web browsers and 93.86% of browser users have a browser engine new enough to support it. C++ is also one of the main places where exceptions are used, so I guess WebAssembly-native exception handling removes a lot of boilerplate here. Both wasmtime and wazero require you to flag into exception support. This is fine; we can just pass to wasmtime and use a custom runner harness for wazero. The annoying part is what happens when my arm machine's anemic build of wasm-opt sees exception handling instructions, causing it to exit. This made the build fail. The solution was to pass at the linking step. This removed one angle of irreproducibility. I guess in the future we could make it use the version of it just built to optimize the output, but that may be a premature optimization for now. The version of clang that I use to compile has some address-sensitive code generation hiding in its exception handling path. Raw pointer values leak into the order a handful of blocks come out in. This surfaces as every build differing from the next by about 29 bytes: To make this easier to spot, here's a partial disassembly: The computation is nearly identical, but the byte order is just different enough to also make the catch references differ. This also fires when you build this pinned version of wasm2js on arm64 machines because its pointer iteration order is different from it is on my workstation. To work around this, I took two steps: I also made a CI job ensure this: To be extra sure, we have this job run on both x86_64 and arm64 hosts. I'd really love to have this be reproducible across hosts, but that's an upstream LLVM bug that I am not powerful enough to tackle. If you work on LLVM and are reading this, it would be nice to set a seed of some kind to ensure that this iteration order is fixed across architectures. At the very least builds are deterministic within architectures. This may have to be good enough for now. Disable address-space randomization for this build using . Create known good sha256 checksums for both x86_64 and arm64 via building this program on machines I trust.

0 views
Xe Iaso 4 months ago

Why are cached input tokens cheaper with AI services?

When you see AI model pricing pages, you usually see things broken down like this: Source: DeepSeek API Docs If you manage to have most of your input tokens be cached, you save a huge amount, in this case $0.20 per million tokens. What does this mean though? What does caching do that makes you save so much, in some cases upwards of tens of kilodollars? Someone explain the cached vs not thing to me for how this is $10,000 worth of savings lol [image or embed] — Chimney Sweepers Local 420 FKA yburyug ( @bobbby.online June 12, 2026 at 12:39 AM Warning I'm gonna be totally honest, I barely understand the basic outline of the math involved here. Where possible I am to not be completely wrong here, but I'm not going to emit something 1:1 accurate with the mathematical truth of large language models' inner workings. Bear with me. When you make an API call to large language model services, you make an API call like the following: That element is the key bit. Every time you accumulate messages from the initial system prompt, initial user request, AI responses and any tool use requests/responses, you add to that array and make it grow bigger and bigger. A good way to think about this is that sending a conversation to a large language model is like having a pair of people share a roll of paper on two different typewriters. Every time you finish your message, you send the roll of paper back to the AI model and it has to re-read through the entire conversation in order to start typing on the end with its response. As the conversation gets longer, this gets more and more expensive because the model has to recalculate its internal state all over again for every additional message. However, large language model inference is complicated but deterministic . Given the same inputs, you will always get the same output. This means that you can use a technique called key-value caching (KV caching) in order to save that intermediate state and use it for next time. Most of the time this cache is a prefix cache because that allows you to just add on more messages to the end of the request pretty easily and be fine. Imagine something like this: If the model has already processed the question about the sky being blue and generated the response about Rayleigh scattering, it doesn't need to process both of those messages again to answer the user's question about sunsets. In production AI model deployments you would put that generated intermediate state into the KV cache so that the model doesn't need to run twice for the same data. This saves time and effort on the side of the AI model provider, and currently model providers decide to pass that savings onto API users in the form of cheaper inference costs for cached lookups. As you develop an application with AI in it, try to avoid changing any inference settings or previous messages between prompts. This makes your application's queries much more likely to read from the cache, making it faster, reducing the environmental impact, and saving you(r users) money.

0 views
Xe Iaso 4 months ago

Giving your Go apps Tigris superpowers

Tigris is S3-compatible, which means you can point the AWS SDK at it and most things just work. The catch is that the Tigris-exclusive features—bucket forking, snapshots, object renaming, and the like—need verbose workarounds because the AWS SDK doesn't know they exist. So we wrote a Go SDK that does. It comes in two flavors: the package is a drop-in replacement for the standard S3 client with first-class methods for the Tigris-specific operations, and is a higher-level client for the common single-bucket case that infers its configuration from the environment so you stop passing the same parameters over and over. You can adopt the Tigris features incrementally without refactoring your existing S3 code, and the simpler API still works against other S3-compatible providers. I wrote up how it works and why we built it over on the Tigris blog.

0 views
Xe Iaso 4 months ago

IPv6 zones in URLs are a mistake

IPv6 is weird. One of the more strange parts of the standard is that every interface's link local addresses are in . If you have a machine with two network interfaces, both of them will be in , so if you have a packet destined to , how do you disambiguate it? The answer is you use IPv6 scopes/zones . The exact format of what goes into a zone is OS dependent, but on Linux it's the interface name and on Windows it's the interface ID. This lets the kernel's routing table know how to handle an address range conflict. On my tower, this would be represented like this: Where is the name of my tower's ethernet device. When you create a host:port bindhost, you normally separate the hostname and port with a colon. IPv6 uses colons to separate hex groups. In order to disambiguate what's the host and what's the port, you typically format the IPv6 address in square brackets, so on port 80 would look like this: And with the right scope it looks like this: Now let's get URL encoding into the mix. From high orbit, you can imagine a URL's format as being something like this: An IPv6 zone would then be part of the hostname, just like with that port 80 example from earlier. So you'd think the URL would be something like this: But if you try to parse this as a URL in Go, you get an error: This happens because URLs can't represent all Unicode values, so any values that don't fit into the grammar of a URL become percent-encoded . This is why sometimes you'll see a in URLs in the wild; that's encoding the ascii space key, which is invalid in URLs. In order to work around this, you need to percent-encode the percent sign in the IPv6 zone: In theory, there is guidance for how to properly handle IPv6 zones in user interfaces in RFC 9844 , but there's no such guidance for URLs . Go also does not seem to follow this RFC in net/url . EDIT: It seems that this behaviour is compliant with RFC 6874 and that this is in fact how it is meant to be done. Our industry confounds me. So in the meantime in order for Anubis to point to IPv6 zoned addresses, you need to encode the with percent encoding. This is horrible, but it seems that this is an edge case that applies to other frameworks, programming languages, and libraries: Maybe some day in the future there will be a better option here. In the meantime my policy of not forking the Go standard library means that this somewhat terrible UX for an edge case is acceptable. I hate it, but what can you do? TL;DR: computers were a mistake. https://trac.nginx.org/nginx/ticket/623 https://github.com/psf/requests/issues/6808 https://datatracker.ietf.org/doc/html/draft-schinazi-httpbis-link-local-uri-bcp-03 -- Browsers don't currently support IPv6 zones because it breaks the concept of an "origin" which is used for many subtle things, this RFC draft attempts to define an zone origin in IPv6 so that browsers have a leg to stand on

0 views
Xe Iaso 4 months ago

"No way to prevent this" say users of only package manager where this regularly happens

In the hours following the news that Redhat Insights' JavaScript packages fell victim to a supply chain attack via NPM, developers and systems administrators scrambled ensure all of their projects were unaffected from a supply chain attack that steals credentials for AWS, GCP, Azure, Kubernetes, HashiCorp Vault, npm, and CircleCI before then self-propagating via said stolen npm credentials and the bypass_2fa setting. This establishes persistence via Claude Code hooks and VS Code task injection. If you have installed the affected package, reprovision your development hardware. This is is due to the affected dependencies being distributed via NPM , the only package manager where these supply-chain attacks regularly happen. "This was a terrible tragedy, but sometimes these things just happen and there's nothing anyone can do to stop them," said programmer Lady Eulah Howell, echoing statements expressed by hundreds of thousands of programmers who use the only package manager where 90% of the world's supply-chain attacks have occurred in the last decade, and whose projects are 20 times more likely to fall victim to supply chain attacks. "It's a shame, but what can we do? There really isn't anything we can do to prevent supply-chain attacks from happening if the maintainers don't want to secure access to their accounts in a robust manner". At press time, users of the only package manager in the world where these vulnerabilities regularly happen once or twice per week for the last year were referring to themselves and their situation as "helpless". For more information, please see upstream documentation published by Redhat Insights' JavaScript packages at the following link: redhat-javascript-clients-06-2026 .

0 views
Xe Iaso 4 months ago

"No way to prevent this" say users of only language where this regularly happens

In the hours following the release of CVE-2026-45584 for the project Microsoft Windows , site reliability workers and systems administrators scrambled to desperately rebuild and patch all their systems to fix a memory safety vulnerability resulting in arbitrary code execution inside the virus scanner Windows Defender. This is due to the affected components being written in C++, the only programming language where these vulnerabilities regularly happen. "This was a terrible tragedy, but sometimes these things just happen and there's nothing anyone can do to stop them," said programmer Dr. Annabelle Connelly, echoing statements expressed by hundreds of thousands of programmers who use the only language where 90% of the world's memory safety vulnerabilities have occurred in the last 50 years, and whose projects are 20 times more likely to have security vulnerabilities. "It's a shame, but what can we do? There really isn't anything we can do to prevent memory safety vulnerabilities from happening if the programmer doesn't want to write their code in a robust manner." At press time, users of the only programming language in the world where these vulnerabilities regularly happen once or twice per quarter for the last eight years were referring to themselves and their situation as "helpless."

0 views
Xe Iaso 5 months ago

Maybe you shouldn't install new software for a bit

In the wake of copy.fail , there are more vulnerabilities that have been announced: Right now would be one of the best times for a supply chain attack via NPM to hit hard. Outside of Linux kernel patches from your distro, I think it's probably a good idea to put a moratorium on installing new software for a week or so. Copy Fail 2: Electric Boogaloo

0 views
Xe Iaso 6 months ago

Small note about AI 'GPUs'

I've been seeing talk around about wanting to capitalize on the AI bubble popping and picking up server GPUs for pennies on the dollar so they can play games in higher fidelity due to server GPUs having more video ram. I hate to be the bearer of bad news here, but most of those enterprise GPUs don't have the ability to process graphics. Yeah, that's right, in order to pack in as much compute as possible per chip, they removed video output and graphics processing from devices we are calling graphics processing units . The only thing those cards will be good for is CUDA operations for AI inference, AI training, or other things that do not involve gaming. On a separate note, I'm reaching the point in recovery where I am getting very bored and am so completely ready to just head home. At least the diet restrictions end this week, so that's something to look forward to. God I want a burrito.

0 views
Xe Iaso 7 months ago

My homelab will be down for at least 20 days

Quick post for y'all now that I can use my macbook while standing (long story, I can't sit due to surgical recovery, it SUCKS). My homelab went offline at about 13:00 UTC today likely because of a power outage. I'm going to just keep it offline and not fight it. I'll get home in early April and restore things then. An incomplete list of the services that are down: Guess it's just gonna be down, hope I didn't lose any data. I'll keep y'all updated as things change if they do. The vanity Go import server The preview site for this blog Various internal services including the one that announces new posts on social media My experimental OpenClaw bot Moss I was using to kill time in bed My DGX Spark for self hosted language models, mainly used with Moss

0 views
Xe Iaso 7 months ago

Vibe Coding Trip Report: Making a sponsor panel

I'm on medical leave recovering from surgery . Before I went under, I wanted to ship one thing I'd been failing to build for months: a sponsor panel at sponsors.xeiaso.net . Previous attempts kept dying in the GraphQL swamp. This time I vibe coded it — pointed agent teams at the problem with prepared skills and let them generate the gnarly code I couldn't write myself. And it works. Go and GraphQL are oil and water. I've held this opinion for years and nothing has changed it. The library ecosystem is a mess: shurcooL/graphql requires abusive struct tags for its reflection-based query generation, and the code generation tools produce mountains of boilerplate. All of it feels like fighting the language into doing something it actively resists. GitHub removing the GraphQL explorer made this even worse. You used to be able to poke around the schema interactively and figure out what queries you needed. Now you're reading docs and guessing. Fun. I'd tried building this panel before, and each attempt died in that swamp. I'd get partway through wrestling the GitHub Sponsors API into Go structs, lose momentum, and shelve it. At roughly the same point each time: when the query I needed turned out to be four levels of nested connections deep and the struct tags looked like someone fell asleep on their keyboard. Vibe coding was a hail mary. I figured if it didn't work, I was no worse off. If it did, I'd ship something before disappearing into a hospital for a week. Vibe coding is not "type a prompt and pray." Output quality depends on the context you feed the model. Templ — the Go HTML templating library I use — barely exists in LLM training data. Ask Claude Code to write Templ components cold and it'll hallucinate syntax that looks plausible but doesn't compile. Ask me how I know. Wait, so how do you fix that? I wrote four agent skills to load into the context window: With these loaded, the model copies patterns from authoritative references instead of inventing syntax from vibes. Most of the generated Templ code compiled on the first try, which is more than I can say for my manual attempts. Think of it like giving someone a cookbook instead of asking them to invent recipes from first principles. The ingredients are the same, but the results are dramatically more consistent. I pointed an agent team at a spec I'd written with Mimi . The spec covered the basics: OAuth login via GitHub, query the Sponsors API, render a panel showing who sponsors me and at what tier, store sponsor logos in Tigris . I'm not going to pretend I wrote the spec alone. I talked through the requirements with Mimi and iterated on it until it was clear enough for an agent team to execute. The full spec is available as a gist if you want to see what "clear enough for agents" looks like in practice. One agent team split the spec into tasks and started building. A second reviewed output and flagged issues. Meanwhile, I provisioned OAuth credentials in the GitHub developer settings, created the Neon Postgres database, and set up the Tigris bucket for sponsor logos. Agents would hit a point where they needed a credential, I'd paste it in, and they'd continue — ops work and code generation happening in parallel. The GraphQL code the agents wrote is ugly . Raw query strings with manual JSON parsing that would make a linting tool weep. But it works. The shurcooL approach uses Go idioms, sure, but it requires so much gymnastics to handle nested connections that the cognitive load is worse. Agent-generated code is direct: send this query string, parse this JSON, done. I'd be embarrassed to show it at a code review. I'd also be embarrassed to admit how many times I failed to ship the "clean" version. This code exists because the "proper" way kept killing the project. I'll take ugly-and-shipped over clean-and-imaginary. The full stack: Org sponsorships are still broken. The schema for organization sponsors differs enough from individual sponsors that it needs its own query path and auth flow. I know what the fix looks like, but it requires reaching out to other devs who've cracked GitHub's org-level sponsor queries. The code isn't my usual style either — JSON parsing that makes me wince, variable names that are functional but uninspired, missing error context in a few places. I'll rewrite chunks of this after I've recovered. The panel exists now, though. It renders real data. People can OAuth in and see their sponsorship status. Before this attempt, it was vaporware. I've been telling people "just ship it" for years. Took vibe coding to make me actually do it myself. I wouldn't vibe code security-critical systems or anything I need to audit line-by-line. But this project had stopped me cold on every attempt, and vibe coding got it across the line in a weekend. Skills made the difference here. Loading those four documents into the context window turned Claude Code from "plausible but broken Templ" into "working code on the first compile." I suspect that gap will only matter more as people try to use AI with libraries that aren't well-represented in training data. This sponsor panel probably won't look anything like it does today in six months. I'll rewrite the GraphQL layer once I find a pattern that doesn't make me cringe. Org sponsorships still need work. HTMX might get replaced. But it exists, and before my surgery, shipping mattered more than polish. The sponsor panel is at sponsors.xeiaso.net . The skills are in my site's repo under . templ-syntax : Templ's actual syntax, with enough detail that the model can look up expressions, conditionals, and loops instead of guessing. templ-components : Reusable component patterns — props, children, composition. Obvious if you've used Templ, impossible to infer from sparse training data. templ-htmx : The gotchas when combining Templ with HTMX. Attribute rendering and event handling trip up humans and models alike. templ-http : Wiring Templ into handlers properly — routes, data passing, request lifecycle. Go for the backend, because that's what I know and what my site runs on Templ for HTML rendering, because I'm tired of 's limitations HTMX for interactivity, because I refuse to write a React app for something this simple PostgreSQL via Neon for persistence GitHub OAuth for authentication GitHub Sponsors GraphQL API for the actual sponsor data Tigris for sponsor logo storage — plugged it in and it Just Works™

0 views
Xe Iaso 7 months ago

Advice for staying in the hospital for a week

As I mentioned in my last couple posts , I recently got out of the hospital after a week-long stay. I survived the surgery, I survived the recovery, and now I'm home with some hard-won wisdom about what it's actually like to be stuck in a hospital bed for seven straight days. If you or someone you love is about to go through something similar, here's what I wish someone had told me. None of this is medical advice. I'm a software engineer who spent a week as a patient, not a doctor. Talk to your actual medical team about actual medical things. There is no way in hell you are going to be productive at anything. I cannot stress this enough. Whatever you're imagining — "oh I'll catch up on reading" or "maybe I'll do some light code review" — no . Stop. Depending on the procedure that landed you there, you're not going to be able to focus long enough to do anything that matters. Your brain is going to be running on fumes, painkillers, and whatever cursed cocktail of medications they have you on. Don't fight it. The name of the game is distraction. Wait, so what do you actually do all day? Scroll your phone. Watch terrible TV. Stare at the ceiling and have thoughts that feel profound but absolutely are not. Let your brain do whatever it wants. You've earned the right to be completely useless for a while. Bring a tablet loaded with comfort shows and don't feel guilty about any of it. Here's the thing nobody tells you: inside the hospital, time ceases to exist. All your memories from the stay get lumped together into one big amorphous blob. Was that conversation with the nurse on Tuesday or Thursday? Did you eat lunch today or was that yesterday? Genuinely impossible to tell. This is a well-documented phenomenon. Between disrupted sleep cycles, medication effects, and the complete absence of normal environmental cues, your brain has nothing to anchor memories to. It's not you being broken — it's the environment. Try not to have any meaningful conversations during this time. You're not going to remember them, and that's going to feel terrible later when someone references something heartfelt they said to you and you just... have nothing. Save the deep talks for when you're home and your brain is actually recording again. Don't even imagine having any meaningful thoughts during your hospital stay. They will evaporate. Okay, this one is weirdly specific but it came up constantly. Cables that glow when you plug them in are great because you can find them in the dark. Your hospital room is going to be a mess of wires and tubes and you need to charge your phone and finding the cable end at 2 AM without turning on a light feels like a genuine victory. But here's the problem: cables that glow when you plug them in are horrible because they glow in the dark. When you're desperately trying to sleep — which you will be, constantly, because the sleep in hospitals is atrocious — that little LED glow becomes your nemesis. Neither option is good. There is no middle ground. Pick your poison. I ended up draping a washcloth over the cable connector at night. Low-tech solutions for low-tech problems. Everything is going to be simultaneously too bright and too dark. The hallway fluorescents bleed under the door at all hours. Someone will come check your vitals at 3 AM with a flashlight. Meanwhile during the day the curtains don't quite block the sun and the overhead lights have exactly two settings: "interrogation room" and "off." You're going to have to grin and bear through this. Bring a sleep mask if you can. It won't fix the problem but it'll take the edge off enough that you might actually get a few consecutive hours of rest. Your ability to focus is going to be gone. Absolutely decimated. Do not fight it. Some days will be better than others — I had one afternoon where I could actually read a few pages of something before my brain wandered off — but mostly you're going to be operating at the cognitive level of someone who's been awake for 36 hours straight. So your advice for a week in the hospital is basically "give up on everything"? My advice is to stop pretending you're going to be a functional human being and just let yourself recover. That is the productive thing to do. Recovery is the job. Everything else can wait. Brainrot yourself. Watch the same comfort show for the fifth time. Scroll through memes. Let your attention span be whatever it wants to be. You've earned it. Honestly, the biggest thing I took away from my hospital stay is that the hardest part isn't the medical stuff — it's the expectations you put on yourself. Let those go. Be a potato. Heal. The world will still be there when you get out, and it'll make a lot more sense when your brain isn't marinating in hospital vibes and post-op medication. Be kind to yourself. You're going through something hard.

0 views