Files
inter-hub/HaskellVibePrimer.md
tegwick ca5ced7ac1
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
This seems to be our first runnable version on railiance01
2026-05-02 22:08:38 +02:00

19 KiB

Haskell Vibe Primer

Hard-won lessons for coding agents working on inter-hub and IHP projects


Quick orientation

Inter-hub is an IHP (Integrated Haskell Platform) v1.5 application on GHC 9.10.3, managed via Nix/devenv. It has two distinct compilation modes with fundamentally different behaviour:

Mode Command Compiler Output Crashes?
Dev devenv up → ghcid GHCi byte-code In-memory No (byte-code avoids static linker)
Production nix build .#docker Native (cabal) OCI image Yes, if archive bug present

A build that works in devenv up does not guarantee nix build .#docker will succeed. The production build hits code paths that ghcid never reaches.

The build host for production is haskelseed (192.168.178.135, root/hcs26!x), not CoulombCore. Do not try to run nix build on CoulombCore.


Part 1 — Known GHC 9.10.3 bugs in this project

Bug 1: module M re-export causes .hi overflow (FIXED in flake.nix)

Symptom:

Data.Binary.Get.runGet at position ~287000000: not enough bytes

Crash occurs while GHC reads a .hi (interface) file.

Cause: IHP generates a hub module Generated/ActualTypes.hs that originally used:

module Generated.ActualTypes
  ( module Generated.Foo
  , module Generated.Bar
  ...  -- 61 sub-modules
  ) where
import Generated.Foo
import Generated.Bar

The module M re-export syntax causes GHC to embed the full exported interface of every sub-module verbatim into ActualTypes.hi. With 61 sub-modules and thousands of typeclass instances, the resulting .hi reaches ~287 MB — exceeding GHC 9.10.3's Data.Binary.Get 274 MB deserialization limit.

Fix (active in flake.nix): The inter-hub-models postUnpack overlay rewrites the export list to explicit T(..) per-type exports:

module Generated.ActualTypes
  ( Foo(..)
  , FooId(..)
  , Bar(..)
  , ...
  ) where

Explicit re-exports store only compact name-references in .hi (not embedded sub-interfaces). ActualTypes.hi drops from ~287 MB to ~51 KB.

Rule: NEVER use module M re-export syntax for generated hub modules with many sub-modules. Always use explicit T(..) exports.

-- BAD: embeds sub-interfaces into hub .hi
module MyHub (module Sub1, module Sub2) where

-- GOOD: stores compact references
module MyHub (Foo(..), Bar, baz) where

Bug 2: Truncated libHSghc-9.10.3-5702.a in Nix store (FIXED on haskelseed)

Symptom:

panic! (the 'impossible' happened)
  GHC version 9.10.3:
  Data.Binary.Get.runGet at position 287686318: not enough bytes

Crash occurs after [477 of 477] Compiling Generated.WidgetVersionInclude — all modules compiled, crash in post-compilation step.

Cause: The Nix-provisioned static archive for the GHC compiler-as-library is truncated:

  • Truncated: ffg3yf2.../lib/.../ghc-9.10.3-5702/libHSghc-9.10.3-5702.a — 287,768,576 bytes
  • Full archive: ffg3yf2.../ghc-9.10.3-partial/lib/.../ghc-9.10.3-5702/libHSghc-9.10.3-5702.a — 289,295,782 bytes

The last AR entry (Expr.o) claims 517,544 bytes but only 82,258 are present. GHC's readAr (via Data.Binary.Get.runGet) panics when it tries to read the full entry.

Why GHC reads this archive at all: The global ghc-with-packages package db is searched during the build (cabal configure does not pass --package-db=clear). That db registers the ghc compiler-as-library package with library-dirs pointing to the directory containing the truncated archive symlink. GHC loads this archive during internal post-compilation processing — even when the package has no Template Haskell.

What does NOT fix it:

  • -fomit-interface-pragmas — reduces .hi size, unrelated to archive loading
  • --disable-shared — affects output type, not input archive reading
  • -fexternal-interpreter -pgmi ghc-iserv-dyn — routes TH to external process, but inter-hub-models has zero TH; the archive read is from a different code path

Fix applied on haskelseed (2026-05-02):

TRUNCATED="/nix/store/ffg3yf2ypnbz3hc31y7nglrkihz0if01-ghc-9.10.3/lib/ghc-9.10.3/lib/x86_64-linux-ghc-9.10.3/ghc-9.10.3-5702/libHSghc-9.10.3-5702.a"
FULL="/nix/store/ffg3yf2ypnbz3hc31y7nglrkihz0if01-ghc-9.10.3/ghc-9.10.3-partial/lib/ghc-9.10.3/lib/x86_64-linux-ghc-9.10.3/ghc-9.10.3-5702/libHSghc-9.10.3-5702.a"
chmod u+w "$(dirname "$TRUNCATED")"
cp "$FULL" "$TRUNCATED"
chmod a-w "$TRUNCATED" "$(dirname "$TRUNCATED")"

If the fix is lost (flake lock update changing GHC version):

# Check archive size before building — should be ~289 MB
wc -c /nix/store/HASH-ghc-9.10.3/lib/ghc-9.10.3/lib/x86_64-linux-ghc-9.10.3/ghc-9.10.3-5702/libHSghc-9.10.3-5702.a
# If ~287 MB, look for ghc-9.10.3-partial/ in same store path and apply patch

Bug 3: --disable-shared causes missing .dyn_hi files (FIXED in flake.nix)

If inter-hub-models is built with --disable-shared, it produces only .hi files but no .dyn_hi (dynamic interface files). The dependent inter-hub-lib package (built with --enable-shared by default in NixPkgs) requires .dyn_hi from its dependencies and fails with:

Failed to load dynamic interface file for Generated.Foo:
  .../Generated/Foo.dyn_hi: does not exist

Do not add --disable-shared to the inter-hub-models configureFlags unless you also suppress --enable-shared in inter-hub-lib (and its transitive dependents). The archive fix (Bug 2) is the correct solution; --disable-shared was a failed hypothesis.


Part 2 — IHP compilation architecture

Compilation layers (from CLAUDE.md)

Layer 1 — stable core (compile once, expensive to change):
  build/Generated/Types.hs      IHP auto-generated from Schema.sql
  Web/Types.hs                  controller/action type definitions

Layer 2 — helpers (only touch if Layer 1 is clean):
  Application/Helper/*.hs       12 helper modules

Layer 3 — working surface (most errors live here):
  Web/Controller/*.hs           59 controllers
  Web/View/**/*.hs              120 views

Layer 4 — wiring (fix last):
  Web/FrontController.hs
  Web/Routes.hs

Fix errors bottom-up, one module at a time. Never touch Layer 1 during an error-fix loop. A single change to Web/Types.hs invalidates all 59 controllers and 120 views simultaneously — equivalent to a full rebuild.

Package split

The project compiles as two separate Cabal packages:

Package Source Contains
inter-hub-models inter-hub-models-src/build/Generated/ All IHP-generated types, ~477 modules
inter-hub-lib inter-hub-lib-src/ Application code: Application/, Config/, Web/

Critical: inter-hub-lib-src has no build/ directory. Generated modules come from the inter-hub-models package, not from the lib source tree. Any script that assumes lib-src contains build/Generated/ will fail. To access generated file content from within inter-hub-lib's build scripts, find inter-hub-models-src dynamically:

_models_src=$(find /nix/store -maxdepth 1 -name "*-inter-hub-models-src" ! -name "*.drv" | head -1)

Generated code — what changes when schema changes

When Application/Schema.sql changes and you regenerate via the IHP IDE:

  • build/Generated/Types.hs — one-liner re-export hub (regenerated, do not edit)
  • build/Generated/ActualTypes.hs — type hub (regenerated, patched by postUnpack overlay)
  • build/Generated/Foo.hs — one per table, data type + instances
  • build/Generated/FooInclude.hstype instance Include for eager loading
  • build/Generated/ActualTypes/Foo.hs — sub-module used by ActualTypes hub

After any schema change, check whether the postUnpack in flake.nix still correctly rewrites Generated.ActualTypes — run nix build --keep-failed and inspect the rewritten file.


Part 3 — Nix/devenv specifics

How the Nix overlay works

The flake.nix has an overlays = lib.mkAfter [...] block in devenv.shells.default that overrides the Haskell package set. It intercepts derivations by pname:

mkDerivation = args:
    let drv = hprev.mkDerivation args;
    in if (args.pname or "") == "inter-hub-models"
       then drv.overrideAttrs (old: { ... })
       else if (args.pname or "") == "inter-hub-lib"
       then drv.overrideAttrs (old: { ... })
       else drv;

postUnpack runs after the source is unpacked but before configure. postConfigure runs after setup configure. Both run inside the Nix sandbox.

Environment variables in Nix builds

  • $sourceRoot — relative path to unpacked source (e.g., inter-hub-models-src)
  • $TMPDIR — build-specific temp directory (e.g., /nix/var/nix/builds/nix-PID-RND/)
  • $NIX_BUILD_CORES — parallelism (8 on haskelseed); NixPkgs Haskell builder passes --ghc-option=-j$NIX_BUILD_CORES by default
  • $NIX_BUILD_TOP — may not be set; prefer $TMPDIR

Configure flags ordering matters

NixPkgs Haskell builder prepends its own configureFlags before yours. Your overlay's flags are appended via (old.configureFlags or []) ++ [ your-flags ]. For boolean flags (--enable-X / --disable-X), the last one wins. For --ghc-option=-jN, all are passed and GHC uses the last one.

Example of NixPkgs flags you must know about:

--enable-shared
--enable-split-sections
--ghc-option=-j8       (from NIX_BUILD_CORES)

To override: append --disable-shared, --disable-split-sections, --ghc-option=-j1 in your configureFlags. But beware: disabling shared breaks .dyn_hi for downstream packages.

The GHC wrapper used in builds (ghc-with-packages) exposes packages from both the wrapped GHC derivation and registered Haskell packages. Its lib/ directory contains symlinks back into the unwrapped GHC derivation. When patching Nix store archives, verify the symlink chain resolves correctly:

readlink -f /nix/store/WRAP-ghc-9.10.3-with-packages/lib/.../libHSghc.a
wc -c /nix/store/WRAP-ghc-9.10.3-with-packages/lib/.../libHSghc.a

Diagnosing nix build failures efficiently

# Get full build log for a specific derivation
nix log /nix/store/HASH-inter-hub-models-0.1.0.drv

# What derivations would be built (dry run)
nix build .#docker --dry-run

# Keep failed build artifacts for inspection
nix build .#docker --keep-failed

# Check if a store path exists (was the drv already built?)
ls /nix/store/EXPECTED-OUTPUT-HASH 2>/dev/null && echo "cached" || echo "not built"

Part 4 — Haskell type system patterns

Interface files (.hi and .dyn_hi)

GHC produces two kinds of interface files:

  • .hi — static interface, for non-shared compilations
  • .dyn_hi — dynamic interface, required when compiling with --enable-shared / -dynamic-too

A package compiled with --disable-shared produces .hi only. Any package depending on it that was compiled with --enable-shared will fail to load the .dyn_hi. Always maintain consistent shared/static settings across a package dependency chain.

Type families vs Template Haskell

type instance declarations (like Include "widgetId" ...) are type-level computations processed entirely by GHC during type-checking. They are NOT Template Haskell, do NOT require the TH runtime, and do NOT trigger archive loading via the internal linker.

Template Haskell requires the TH evaluator (internal or external interpreter) only for modules with:

  • {-# LANGUAGE TemplateHaskell #-} pragma
  • Splice expressions $(...) or quasi-quotes [| ... |]
  • deriveGeneric, makeLenses, etc. (lens/generics libraries)

If a module has none of these, -fexternal-interpreter has no meaningful effect on it.

module M re-export vs explicit exports

-- EMBEDS sub-interface into .hi (dangerous for large hubs):
module Hub (module Sub) where
import Sub

-- Stores compact name-reference in .hi (correct for large hubs):
module Hub (Foo(..), Bar, baz) where
import Sub (Foo(..), Bar, baz)

The module M form is fine for small modules (< 10 re-exported sub-modules with modest interfaces). It becomes a GHC 9.10.3 bomb for generated hubs with 50+ sub-modules full of typeclass instances.

AR archive format and readAr

GHC's internal static linker uses Data.Binary.Get.runGet to read AR archives (.a files). Unlike ar t (which only reads headers), readAr reads all entry content. A truncated archive that passes ar t will still panic readAr. If you see:

panic! (the 'impossible' happened)
  Data.Binary.Get.runGet at position N: not enough bytes

Run ar tv /path/to/suspect.a | tail -5 — if the last entry shows a size, check whether wc -c /path/to/suspect.a is substantially smaller than expected.


Part 5 — Build compile tools and discipline

Compile check scripts

Inside devenv shell:

scripts/compile-check          # full build via ghcid (all layers), errors → /tmp/ihub-compile-errors.txt
scripts/compile-check --bg     # background-friendly (no colour/title escape codes)
scripts/compile-check-core     # Layer 1+2 only — verify clean base

Error-fix discipline

  1. Fix bottom-up: Layer 1 → 2 → 3 → 4
  2. Fix one module at a time — wait for ghcid to reload before touching the next module
  3. Never change Layer 1 (Web/Types.hs, Generated/Types.hs) while debugging Layer 3 errors — it restarts the entire recompile
  4. Generated/Types.hs is auto-generated from Application/Schema.sql. Edit the schema in IHP IDE, not the generated file

Reading GHC error messages

GHC errors reference source locations, not compiled output. When you see:

Web/Controller/Foo.hs:42:5: error:
    • Couldn't match expected type 'UUID' with actual type 'Text'

The fix is in Web/Controller/Foo.hs:42, not in any generated file.

When you see:

Failed to load interface for 'Generated.Foo'

Check that Generated.Foo is in the inter-hub-models package and that models compiled successfully first.

Parallel compilation flag (-j)

GHC's -j flag controls parallel module compilation within a single package. On haskelseed (2 CPU, ~3.8 GiB RAM), NixPkgs sets -j8 (from NIX_BUILD_CORES=8). The overlay overrides to -j1 for the models package to prevent OOM on the constrained host. Do not increase this without verifying available memory:

free -m  # on haskelseed

The env var GHCRTS = "-A32m -M2g" caps the GHC heap at 2 GiB and sets minor GC allocation to 32 MB. These are set in devenv.shells.default.env.


Part 6 — Guardrails: expensive mistakes to avoid

NEVER do these

Action Why it's expensive
Edit build/Generated/Types.hs directly Regenerated on next schema sync; change is lost. Also, editing it changes Layer 1 and invalidates all 477 modules
Add module M re-export syntax to a new generated hub module Embeds sub-interfaces → .hi overflow → GHC crash
Change Web/Types.hs or Web/Types.hs during a Layer 3 error loop Restarts compile of all 59 controllers + 120 views
Add --disable-shared to models without removing it from lib Missing .dyn_hi crash in every downstream package
Hardcode Nix store hashes in postUnpack scripts Hash changes with every schema change; use find /nix/store instead
Run nix build .#docker on CoulombCore Insufficient RAM (< 4 GiB), will OOM during GHC compilation
Trust ar t to validate an archive ar t reads only headers; GHC reads content. Use wc -c and compare to expected size
Assume devenv up success = nix build success Different compilation modes; static linker issues only surface in nix build

Before modifying flake.nix overlays

  1. Check what derivation hash the current flake produces: nix build .#docker --dry-run
  2. Understand layer dependencies: changing inter-hub-models configureFlags invalidates models AND all downstream (lib, binaries, docker image)
  3. Test postUnpack scripts locally first: simulate with bash -n yourscript.sh and run awk commands against sample files
  4. Verify .dyn_hi will be produced if --enable-shared is set (which is the NixPkgs default)

Diagnosing a new crash before trying fixes

  1. Get the full log: nix log /nix/store/HASH-inter-hub-models-0.1.0.drv
  2. Find the crash position: Data.Binary.Get.runGet at position N
  3. Determine which file is being read at that position (check file sizes of .hi, .a, .conf etc.)
  4. Check ar tv suspect.a | tail -5 to see if the last AR entry header is valid
  5. Only then propose a fix — flag-based fixes almost never work for archive truncation issues

Part 7 — IHP-specific Haskell patterns

IHP record access

IHP generates HasField instances. Access fields with:

record.fieldName          -- accessor (uses OverloadedRecordDot or get)
record |> set #fieldName value   -- functional update

IHP type family: Include

-- Generated/WidgetVersionInclude.hs (typical):
type instance Include "widgetId" (WidgetVersion' widgetId) =
    WidgetVersion' (GetModelById widgetId)

This is pure type-level. Include is a closed type family that fills in the concrete type for an association when you do eager loading with fetchRelated. It is NOT Template Haskell.

IHP controller pattern

-- Each action is a function returning an IO Response (via implicit context)
action ShowWidgetAction { widgetId } = do
    widget <- fetch widgetId
    render ShowView { .. }

Controller type errors almost always mean a mismatch in Web/Types.hs — the action type declared there must match the implementation.

Schema → Generated code cycle

Edit Application/Schema.sql
  → IHP IDE at localhost:8001 generates build/Generated/
  → Regenerated: Types.hs, ActualTypes.hs, Foo.hs, FooInclude.hs
  → Also regenerates Web/Types.hs (action types) and Web/Routes.hs
  → Run compile-check to verify

After schema changes, always verify the postUnpack overlay in flake.nix still produces valid Haskell. The _types / _exports awk scripts in postUnpack depend on the generated file structure remaining stable.


Quick reference card

# Build status checks (on haskelseed)
nix build .#docker --dry-run                     # what would be built?
nix log /nix/store/HASH-inter-hub-models-0.1.0.drv  # full build log
wc -c /nix/store/ffg3yf2.../libHSghc-9.10.3-5702.a  # verify archive size (must be ~289 MB)

# If archive is truncated (287 MB), apply patch:
# Full archive is at same derivation's ghc-9.10.3-partial/ subdirectory
# See project_ghc_build_fix.md in Claude memory for exact commands

# Compilation layers (in devenv shell)
scripts/compile-check-core    # Layer 1+2 only
scripts/compile-check         # Full build, errors → /tmp/ihub-compile-errors.txt

# Schema regeneration
# Use IHP IDE at localhost:8001, then run compile-check

# Production build → push → deploy
nix build .#docker
skopeo copy docker-archive:result docker://92.205.130.254:32166/coulomb/inter-hub:$(git rev-parse --short HEAD)