httail

(hypertext tail)

Now that I've been using makeci for a little bit, one rough edge I've been running into is the circumstance where everything doesn't immediately update.

journcy.net: "makeci"

Normal CI problems: did I do something wrong, did something randomly break, did it just decide it needs to pull some Nix package again even though nothing's changed since last time, etc. So far I've solved this problem via the perfectly acceptable method of logging on to my server and checking the container logs, but this doesn't thrill the heart.

What *would* thrill the heart? Why, being able to read the logs from my terminal via a conveniently printed command received from the remote on push, of course.

I consider writing something other than a shell script for once

Tangled has this neat feature (...not that I've ever used it...) where when it runs a CI job as a result of a push it'll give you an SSH command to run that will let you tail the job log. I don't need to be building a whole SSH shell for this, but I like the UX. Really what I need is to be able to tail a file off of disk, just like 'tail -f' does! Except over the network, so probably over HTTP.

I knew there was some kind of streaming support in HTTP, and indeed there are like half a dozen different kinds of streaming support in HTTP depending on what you mean by "streaming," or "HTTP":

http.dev: "Streaming"

As indicated by its position at the top of the list, I was interested in chunked transfer encoding. This is when you set the 'Transfer-Encoding' header to 'chunked' and exclude the 'Content-Length' header, and then emit however many body segements you like, each prefixed with its length as a hexidecimal number. It's used for this kind of dynamically generated text/log streaming all the time, apparently.

With my ultimate goal in hand--spit out hunks of file over HTTP as HTTP chunks--I dove into implementation details. I've always had a soft spot for CGI, but it seemed like this sort of long session might be better suited to FastCGI, and there are Gleam libraries for both.

Louis Pilford: "cgi"John Downey: "fcgi"

Then, the business logic: I want the behavior of 'tail -f', but what is that, exactly? It uses inotify to watch for file events when inotify is available in the kernel, but also uses a timeout (the '-s' flag). Plus, reading N lines from the end of the file, but it would probably be good not to read *too* much into any given response chunk, to avoid latency or sending potentially large chunks, not that that's likely in the actual production usecase, but still.

You can read files as streams in Gleam using the 'file_streams' library, which has a nice little API.

Richard Viney: "file_streams"

What about inotify, though? Things are much thinner on the ground here. There is no Gleam library exposing the inotify or providing file watching behavior that I could find; Lustre solves this problem in its dev tooling by shelling out to the Node 'fs' API.

lustre_dev_tools: "bun-watcher.js"

There is an Erlang package wrapping inotify, but wrapping an Erlang library that itself uses native code is getting pretty advanced for me with my nonexistent Erlang skills and knowledge, not to mention the entire API design for Gleam. It sounds like a fun project, but not something I'd have time for in a single night, unfortunately. (The posts must flow!) Also, I already owe you one finished Gleam wrapper library, I don't need to promise another.

Mats Cronqvist, Sven Heyll: "inotify"journcy.net: "Pele wrapper library"

My options seemed like 1. doing something like what Lustre does (except this isn't so great an option, because 'file_streams' works best on the Erlang target), 2. using 'shellout' to run inotify-tools as a separate process (the whole point of FastCGI is to not spawn a process per request...), or 3. giving up on inotify and just doing a polling thing, which would be fine.

It was at about this point that I noticed that maybe, just maybe, I was overcomplicating things.

I give up and write a shell script

First of all, there's no reason you can't use CGI for something like this; it'll work just fine. (I concluded, with as little evidence as I'd had when I concluded the opposite. However, this time I was right, so.) Second of all, if I was using CGI I could write a shell script and literally use 'tail -f', which would 1. be very simple, and 2. be very funny.

I had a brief moment of panic when I discovered I had misread and/or overlooked the fact that nginx doesn't natively support CGI, but my worries were eased when it became clear that the adjacent search result held the solution to my problems.

Chizhong Jin et al.: "nginx-cgi"

As the README puts it:

CGI is neither a demon nor an angel. It is simply a tool. Just like a chef's knife in the hands of a cook or a sword in the hands of a warrior, you won't use a sword for cooking, nor you take a chef's knife to the battlefield. The same goes for CGI, it has its appropriate scenarios, and it should not be misused or demonized.

Cheers, I'll drink to that.

I had a few more slightly less brief moments of panic when I started trying to build the plugin module on Alpine, but in the end I decided that I was trying to write a CGI script, not successfully run an nginx build, and asked Opus 5.5 for help. (They swiftly guided me through troubleshooting my errors. nginx has this whole neat build code it uses to decide if plugin modules are compatible with the running binary, you just gotta configure your build so that the versions and the codes match, basically.)

With my plugin module building I wrapped the whole thing in a Dockerfile, poked at an nginx config file until it looked right, starting considering how I might need to use 'stdbuf' or something to buffer my tail output and how I could generate the chunk encoding output appropriately, and, oh, reread the nginx-cgi README, would you look at that:

### Streaming

Nginx-cgi has streaming support for both request and response body. For example, we can implement a simplest online caculator by `bc`:

```shell

#!/bin/sh

echo ""

bc 2>&1

```

Then we can test our caculator by `curl`:

```shell

curl 127.0.0.1/cgi-bin/bc.sh --no-progress-meter -T .

```

The nginx-cgi plugin is smart enough to choose the correct way to return the request body. If it got all output soon enough, it will output the body in once. If the output is delayed, it will output the body chunkly(HTTP 1.1) or streamingly (HTTP 1.0).

Help, my clever plan keeps getting easier.

So, yeah, turns out I could *literally* just run 'tail -f' in a shell script in CGI. I made the necessary tweaks to makeci to output logs to a file rather than stdout, started overcomplicating the changes to the post-receive Git hook before realizing that could also be extremely straightforward (was going to generate my own unique identifier and route it through the message, then I realized ntfy assigns a unique ID to every message and I could just use that), and wrote 49 lines of shell code to precede my call to 'tail(1)':

Reproducing it here because I haven't decided if I'm going to put this anywhere. I think it needs more love first at a bare minimum. Like, say, query string handling that isn't, uh, that.

One shared Docker volume later, and it works:

I didn't give makeci permission to pull anything other than journcy.net, so this is "task failed successfully." (The '-N' flag for curl is '--no-buffer', which stops it trying to buffer chunks and instead just prints them immediately.)

So that's what I've got! Basically a slightly scuffed HTTP server that only serves GET requests and will send you all the changes to a file indefinitely. I probably could've written this in 100 lines of Python, but the 3500 lines of C code can't hurt me if I don't read them.

I have more ideas for super simple job reporting for makeci, but I think those need more time in the oven. For now, I will be content with no longer needing to type Docker commands just to see that Nix is taking three minutes to download a NAR or whatever.

The kanji of the day is 未

This kanji was of course paired with the devilish 末/LAST PART. I think Wiktionary's "See also" header captures the state of affairs well:

*See also:* 木, 夫, 末, 禾 and 耒

I had a fun coincidental run-in with 未定 ("not yet fixed, pending, TBD" with 定/FIX) around the time I learned this kanji, which is always satisfying; in this case I was checking up on the latest chapter raw of a manga I'm mildly obsessed with and learned that we had progressed from a late due date to no due date at all! Fortunately things are now back on schedule, and this month's chapter just came out yesterday, so I can struggle through half of the first few speech bubbles and then look at the pictures to my heart's content until the fan TL drops in a week or two. One day...

Discuss this post on Bluesky