The Haskell Playground

Tom Smeding September 15, 2024 [Playground]

The playground (play.haskell.org) allows you to run single-file Haskell programs right from your browser, and share them with others. In this post, I will introduce the playground and give some implementation details.

Being able to run small Haskell programs in a browser is useful if you want to share a snippet of Haskell code with someone in such a way that the recipient can easily read, modify and execute the code. Another use-case for the playground is if you're on the go (for example at a functional programming conference!) and want to show off some Haskell to your friends.

If those friends are performance-minded, they might ask you what the code compiles to. Fortunately, the playground can help there too! By clicking the "Core" button in the playground, you can see the intermediate representation that GHC converts all Haskell code to, and where it performs most of its optimisations; it is a stripped-down version of Haskell where everything is explicit. (Among other things, every memory allocation is marked by a let expression in Core; this can be useful to diagnose performance issues.) If you are courageous, you can even look at the native assembly that GHC generates (with the "Asm" button).

The playground is, of course, implemented in Haskell. The underlying web server is Snap, which I like because it's not too heavy-weight (for a web server, that is), and doesn't get in your way much.

Origins

The roots of the playground are on IRC (Internet Relay Chat) — which does, indeed, still exist. Most of the general Haskell IRC activity is in #haskell on libera.chat. IRC is an old and simple protocol, and multi-line messages are not supported, so what do you do if you want to ask for help with a (multi-line) piece of code? You put it on a pastebin and share the link. Which pastebin?

People were using all kinds, and some of the regulars in the channel were complaining 1. that some of those websites were annoying/slow, and 2. that beginners would often only upload half the code, no output, only paraphrase the compiler error, etc. Thus the idea of a custom pastebin was born:

<sm> also: a new paste bin customized/structured for #haskell to elicit more info ? eg cabal or yaml file, stack yaml file, command, output, platform..
<tomsmeding> sm: building that pastebin service would be trivial, question is who’d host it
<sm> trivial eh :)
<sm> if you build it I’ll host it :)
<tomsmeding> sure lol

And so a pastebin was born (still need to chase them up on that offer). But soon people were back asking for more stuff:

A GitHub issue with title "Playground on haskell.org?"

Somehow, this also happened, and it has been running on a small virtual server since then. For now, this is quite sufficient, but should server load increase in the future, we'll need to re-evaluate.

Implementation

When I wrote the playground, I was in an optimistic mood and prepared it for horizontal scaling to multiple compilation servers. For now this functionality is essentially unused (there is just one worker), but it does mean that the implementation of the playground is modularised in two executables:

Both server and worker accept connections using HTTP, but that can be easily upgraded to HTTPS in the usual way: put a reverse proxy (such as Caddy or Nginx) in front of it and terminate TLS there. (Caddy has the bonus feature of having built-in support for requesting TLS certificates from Let's Encrypt, which makes this even easier.)

Let's dive into two more technical aspects of the playground implementation: sandboxing and the build environment.

Sandboxing

I already mentioned that the worker has to sandbox execution of submitted programs: since the primary functionality of the playground is literally "please execute arbitrary code on my machine", we had better make sure that we can do that safely! The typical way to do sandboxing nowadays seems to be containers (e.g. using Docker), but those frameworks typically do much more than we need, and have the number of CVEs to match. So instead, the playground takes the much safer (?) approach of a stack of three hacky shell scripts that successively:

  1. Use Linux user namespaces (using systemd-run) to set resource quotas: this limits CPU use (to a single CPU core), memory use, and the number of concurrent threads.
  2. Use Linux cgroups with bwrap (also used by Flatpak) to chroot to a Ubuntu distro and prevent access to disk, network, other processes, etc. Read-only access is provided to ~/.ghcup, because that's where the GHCs live.
  3. Actually look at what the job is asking for, and do that.

This is convoluted, and it can surely be simplified (without reaching for bloated containerisation solutions). I just don't know how, so if you do, please reach out. (And if you'd like to try to break the sandboxing, I'll gladly help you set up the playground on your own machine so that you can test without spoiling the fun for others. ;) )

The build environment: making Haskell packages available

Having just the boot packages available would make for a rather bare playground (no System.Random, etc.), so we clearly need to provide something more. The current policy for additional packages is "if you can convince me that a package is useful that a package is useful and it doesn't unnecessarily bloat the dependency tree, I'll add it together with its transitive dependencies". This has happened multiple times already (e.g. 1, 2, 3). This policy is admittedly completely subjective, but it's unclear if there is a real alternative. The only "principled" option seems to be making all of Stackage available, but that is both unwieldy (there's too many packages in there so it takes a huge amount of disk space) and not always possible (the playground aims to support new GHCs immediately after release, and Stackage naturally lags behind).

In any case, we need some way of bringing additional Haskell packages in scope when compiling submitted programs. One would naturally reach for cabal here; a possible design would be to have a Cabal project per GHC version and upon job submission replace its Main.hs with the submitted program, after which it is a matter of invoking cabal run. However, this design has some disadvantages:

Perhaps cabal could be improved on that last point, but even without that, there is enough reason for the playground to do something different. What happens instead is the following:

There is thus one such generated shell script for each GHC version on the playground; these are what is actually run when a job is handled by a worker. This way, the overhead is as low as possible: just ghc is invoked, and almost nothing else (apart from the sandboxing machinery). The overhead from sandboxing is reduced to a minimum by starting a new sandbox immediately after the previous one exited, so that one should be already running (waiting for a program to compile) when the next job arrives.

Manual work

The awkward part of the current setup is that I need to manually prepare a consistent package list for each GHC version. I have some automation for that, but it would of course be nicer if the process of adding a new GHC version to the playground was automatic.

The trouble is that this is actually non-trivial. As described above, there is a "wish list" of packages that we'd like to be available, but not all those packages build with all GHC versions — either because they haven't been updated for the latest GHC yet, or because they exist only for certain GHC versions by design (e.g. ghc-heap). It is not obvious how to effectively determine a maximal subset of this wish list that builds together! Because package dependency constraints need not be monotonic (e.g. package B may depend on A (>= 1 && < 3) || (>= 4 && < 6)), simply using the newest of each package on the wish list (or oldest, as some people seem to prefer) is not necessarily sufficient. This is why the Cabal dependency solver is a thing.

The process of "whittling down" the wish list to something that does build together, guided by cabal dependency solving errors, is partially automated, but a comprehensive automated solution (that is nevertheless simple enough to maintain and not introduce a slew of robustness bugs of its own) would be nicer. If you have ideas, please jump into the issue tracker and tell me!

Future

The playground receives a healthy amount of traffic, and it seems to be useful to people, but of course there is a lot that can still be improved. A number of ideas are already listed in the issues on the GitHub repository, but if you have an idea for an improvement that's not yet on the list — be it to the design, the user experience, or the implementation — or if you've found a bug and would like to report it, feel free to open an issue. If you would like to work on the project yourself (and it's more than a one-line fix), please open an issue first; that way we can prevent extra or duplicate work on both sides.

As an example of a recently added feature, the GHC error output on the playground now links to the Haskell Error Index (for GHC >= 9.6). (If you get a GHC error, such as this one, click on the link, and find that it has not been explained yet in the Error Index, they're looking for contributors!) Thanks to @MangoIV for suggesting this feature.

If you want to say hi, I often hang out in #haskell on libera.chat. Happy Haskelling!