Finding my first bug in Hugo

January 24, 2024

Here are my notes on diagnosing a bug in Hugo. Hugo is the static site generator I use for this website.

When creating a new post, setting the publish date to a future date causes the Hugo new content command to panic. See the issue I have created on GitHub here.

Since I thought it would be a nice exercise to try to recreate it, I first set up a Nix environment in a folder, and clone Hugo into a subfolder. Since I need a debugger, I decided I would use Delve. I also use direnv together with Nix to make programs available in the shell. Here are some notes on how to set up direnv with Nix.

I create an empty folder and populate it with the necessary Nix files as follows:

mkdir hugo
cd hugo
touch flake.nix shell.nix
echo "use flake" > .envrc

I paste the following contents into flake.nix:

# Contents of flake.nix
  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs/nixos-23.11";
    flake-utils.url = "github:numtide/flake-utils";
  outputs = { self, nixpkgs, flake-utils, ... }:
    flake-utils.lib.simpleFlake {
      inherit self nixpkgs;
      name = "Hugo";
      shell = ./shell.nix;

This uses flake-utils to abstract away creating flakes for each system. I then specify the packages I want to use in a shell.nix file:

# Nix shell file
{ pkgs ? import <nixpkgs> {} }:
pkgs.mkShell {
  buildInputs = [

We can then make this development shell available by running the following:

$ direnv allow
direnv: loading ~/projects/hugo/.envrc
direnv: using flake
direnv: nix-direnv: using cached dev shell
direnv: export [...]

Now, I can clone Hugo into a subfolder, and compile Hugo there using a build tool called Mage, as instructed by the contribution guidelines in the Hugo documentation.

git clone
cd hugo
mage hugo

That worked flawlessly. We create a new test site in a temporary directory and see if we can make Hugo crash again.

./hugo new site /tmp/hugo-test
echo '[frontmatter]
publishDate = [":filename"]
' > /tmp/hugo-test/hugo.toml

We tell Hugo to use /tmp/hugo-test as a working directory and try to create a new post, hopefully triggering the crash:

./hugo new --source /tmp/hugo-test content/posts/2025-01-01-(random).md

And we get the same stack trace:

panic: [BUG] no Page found for "/tmp/hugo-test/content/posts/"

goroutine 1 [running]:*contentBuilder).applyArcheType(0x14000ab6f00, {0x14000ac9ad0, 0x30}, {0x140003b8240, 0xa})
        /Users/justusperlwitz/projects/hugo/hugo/create/content.go:278 +0x238*contentBuilder).buildFile(0x14000ab6f00)
        /Users/justusperlwitz/projects/hugo/hugo/create/content.go:246 +0x16c
        /Users/justusperlwitz/projects/hugo/hugo/create/content.go:105 +0x24c, {0x0, 0x0}, {0x16b2969e3, 0x21}, 0x0)
        /Users/justusperlwitz/projects/hugo/hugo/create/content.go:109 +0x498

Since running the command crashes directly without requiring any intervention, we can run the command from Delve and step into the crashing function and see what goes wrong. Further, Delve compiles go applications itself and makes a debuggable version available. This is very useful, of course. When using GDB to debug C programs, a separate build step is necessary after every source change.

dlv debug -- new --source /tmp/hugo-test content/posts/2025-01-01-(random).md

Doing this, we can throw ourselves right into the panic stack trace:

(dlv) continue
> [unrecovered-panic] runtime.fatalpanic() /nix/store/sim1xn9lg19nfr4n8gxynd6c7h3yzalw-go-1.21.5/share/go/src/runtime/panic.go:1188 (hits goroutine(1):1 total:1) (PC: 0x10443bc00)
Warning: debugging optimized function
        runtime.curg._panic.arg: interface {}(string) "[BUG] no Page found for \"/tmp/hugo-test/content/posts/2025-01-01...+10 more"
  1183: // fatalpanic implements an unrecoverable panic. It is like fatalthrow, except
  1184: // that if msgs != nil, fatalpanic also prints panic messages and decrements
  1185: // runningPanicDefers once main is blocked from exiting.
  1186: //
  1187: //go:nosplit
=>1188: func fatalpanic(msgs *_panic) {
  1189:         pc := getcallerpc()
  1190:         sp := getcallersp()
  1191:         gp := getg()
  1192:         var docrash bool
  1193:         // Switch to the system stack to avoid any stack growth, which

My intuition is that publishDate is somehow pulled into the execution, and if it is in the future, it being considered a draft is somehow relevant. I find a few functions that work on publishDate, and I break on them:

b /shouldBuild/
b hugo/hugolib/content_map_page.go:342
b hugo/hugolib/content_map_page.go:371

Finally, we see our page in assemblePages in content_map_page.go:

>*pageMap).assemblePages.func1() ./hugolib/content_map_page.go:372 (PC: 0x102ea18ec)
   367:                 if err != nil {
   368:                         return true
   369:                 }
   371:                 shouldBuild = !(n.p.Kind() == kinds.KindPage && m.cfg.pageDisabled) && m.s.shouldBuild(n.p)
=> 372:                 if !shouldBuild {
   373:                         m.deletePage(s)
   374:                         return false
   375:                 }
   377:                 n.p.treeRef = &contentTreeRef{

And shouldBuild evaluates to false:

(dlv) print shouldBuild
(dlv) print s

For some reason, this means that the page should not be built and therefore a file needed to build the post (keep in mind that we are only creating a new file, not building the site) is unavailable.

I was able to workaround this issue by telling Hugo that we are in the future:

hugo new content --clock 2030-01-01T00:00:00Z content/posts/2025-01-01-(random).md

To be continued!

I would be thrilled to hear from you! Please share your thoughts and ideas with me via email.

Back to Index