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 = [
pkgs.go
pkgs.mage
pkgs.delve
];
}
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 git@github.com:gohugoio/hugo.git
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/2025-01-01-14637.md"
goroutine 1 [running]:
github.com/gohugoio/hugo/create.(*contentBuilder).applyArcheType(0x14000ab6f00, {0x14000ac9ad0, 0x30}, {0x140003b8240, 0xa})
/Users/justusperlwitz/projects/hugo/hugo/create/content.go:278 +0x238
github.com/gohugoio/hugo/create.(*contentBuilder).buildFile(0x14000ab6f00)
/Users/justusperlwitz/projects/hugo/hugo/create/content.go:246 +0x16c
github.com/gohugoio/hugo/create.NewContent.func1()
/Users/justusperlwitz/projects/hugo/hugo/create/content.go:105 +0x24c
github.com/gohugoio/hugo/create.NewContent(0x140005d7040, {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
:
> github.com/gohugoio/hugo/hugolib.(*pageMap).assemblePages.func1() ./hugolib/content_map_page.go:372 (PC: 0x102ea18ec)
367: if err != nil {
368: return true
369: }
370:
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: }
376:
377: n.p.treeRef = &contentTreeRef{
And shouldBuild
evaluates to false:
(dlv) print shouldBuild
false
(dlv) print s
"/posts/__hb_2025-01-01-17433__hl_"
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!