Sanity check your dependencies with Template Haskell

May 5, 2021
Written by Artyom Kazak

The problem

This post describes a small pattern that lets us avoid bugs in production.

It is very old, and has been around since the days of configure scripts. However, Haskell packages usually don't have configure scripts, and Setup.hs is slowly dying.

First, some givens:

  • Let's say you have package A with a stack.yaml or default.nix file in the repository.
  • Let's also say that you have package B that depends on package A. Naturally, it will also have a stack.yaml or default.nix file.
  • Finally, let's say that for interesting reasons those packages can't live in a monorepo.

Now, you might already know that whatever you write in stack.yaml won't be taken into account when somebody builds a package depending on your repository. So, if you ever wrote this:

# stack.yaml

  # Our fork of 'somelib' fixing X, Y, Z
  - git:
    commit: ... had better make sure that whoever depends on your repository will also use the somelib fork. If you didn't know this — well, now you do.

There are a few obvious solutions to make sure everyone will use the right version of the library:

  • Bump the forked lib's version to something like 99999 and add a bound check. Breaks if somebody uses allow-newer: true or doJailbreak.
  • Add a unit test. Doesn't work — your repository is fine, it's some other repository that is the problem. (Nix runs dependencies' tests by default, but people often disable those.) Also, some bugs are very tricky to write a test for.
  • Create a custom snapshot (if you are using Stack) and make everyone depend on it. Good, but not perfect, since custom snapshots can be overridden.
  • Add a big warning in Eh.
  • Watch others' repositories like a hawk. Also eh.

The solution

Instead, this is what I came up with: I include an extra module, SanityCheck.hs, that does nothing other than verify that we have the right dependencies.

Let's say I hit a rare 'cryptonite' bug — AES breaks nondeterministically on some machines. It goes away when I disable AESNI. Luckily, cryptonite provides a Cabal flag to disable AESNI, but I can't use something like CPP to check build configuration of your dependencies — only the version numbers. So instead I do this:

{-# LANGUAGE TemplateHaskell #-}

module SanityCheck where

import Control.Monad
import Crypto.System.CPU
import Language.Haskell.TH

    when (AESNI `elem` processorOptions) $
      reportError $ unlines [
        "cryptonite was compiled with Cabal flag support_aesni=true!",
        "    This can lead to nondeterministic runtime failures, see:",
        "" ]
     pure [])

Let's say I absolutely need to make sure that I use a particular fork of Aeson that doesn't output numbers in scientific notation. I do this:

import Data.Aeson

    let hasDoubleFix = and [
          encode (0.00000000001 :: Double) == "0.00000000001",
          encode (1000000000000 :: Double) == "1000000000000",
          encode (toJSON (0.00000000001 :: Double)) == "0.00000000001",
          encode (toJSON (1000000000000 :: Double)) == "1000000000000" ]
     unless hasDoubleFix $
       reportError $ unlines [
         "aeson does not have the Double encoding fix!",
         "    This will lead to bugs in production." ]
     pure []

(Why do I check encode with and without toJSON? Because those are going to go via entirely different code paths, thanks to toEncoding.)

Finally, let's say the library doesn't expose a list of feature flags (like cryptonite) and the bug is not easy to reproduce. In this case, I just make sure to always export a constant when I fork a library:

hasSuchAndSuchFix :: Bool
hasSuchAndSuchFix = True

This is the easiest case. We don't even need Template Haskell here — our package just would not compile if the constant is present.

Now I'm happy not having to watch others' repositories like a hawk.

Have anything to add? Write a comment on Reddit, or ping us on Twitter.