Repository navigation
sphinxdocs: relative markdown links between pages fail to resolve (myst.xref_missing) #3977
Description
Important
Corrected below — see this comment.
Two cells of the matrix in this description are wrong: they were action-cache hits that never re-ran Sphinx.
Stock sphinxdocs fails in all execution modes, not only sandboxed, and copying sources instead of
symlinking them does fix the default and local cases. The description below is kept as filed.
What happened
With sphinx_docs + myst_parser, a relative markdown link between two pages
(e.g. [Add an entity](add-entity.md)) resolves correctly when the Sphinx action
runs unsandboxed, but fails with myst.xref_missing when the action runs under
linux-sandbox. With -W (which we use as a docs-freshness gate) this is a hard
build failure.
_sources/how-to/register-dataset.md:8: WARNING: Unknown source document
'/home/.../execroot/_main/bazel-out/k8-fastbuild/bin/.../_ontology_docs/_sources/how-to/add-entity'
[myst.xref_missing]
Note the two paths involved. The document being processed is reported at the
sandbox execroot:
/home/.../sandbox/linux-sandbox/58/execroot/_main/bazel-out/.../_sources/how-to/register-dataset.md
but the resolved link target is at the real execroot:
/home/.../execroot/_main/bazel-out/.../_sources/how-to/add-entity
Inside the sandbox, bazel-out is a symlink into the real output tree. MyST
appears to resolve the relative target to a canonical (symlink-resolved) absolute
path, which drops the sandbox prefix, while Sphinx's docnames remain relative to
the srcdir it was given (the sandbox path). The two can then never match.
Reproduction matrix
Same target, same conf.py, same sources, only the execution strategy varies:
| strategy | result |
|---|---|
| worker (default) | pass |
--strategy=SphinxBuildDocs=local |
pass |
--strategy=SphinxBuildDocs=sandboxed |
fail (myst.xref_missing) |
Because worker mode is the default, this is easy to miss — and it means a docs
build is only correct as long as it is not sandboxed.
What does and doesn't break
:doc:roles andtoctreeentries resolve fine in all three modes.- Only relative markdown links between pages fail.
Not caused by the source-tree symlinks (checked)
_sphinx_source_tree_impl symlinks each source into the _sources dir, so the
obvious hypothesis is that the per-file symlinks are what MyST resolves through. I
patched _relocate to copy regular files instead of symlinking them and re-ran the
matrix: the failure is unchanged (the resolved target path moves from the
original source tree to the real execroot _sources, but still doesn't match the
sandbox path). So copying sources isn't a fix; the symlinked component is
bazel-out itself within the sandbox.
Environment
- sphinxdocs 2.2.0 (bzlmod), Bazel 8.x, Linux
- sphinx-autoapi as the API-doc generator,
myst_parserfor markdown, furo theme - Sources staged via
srcs+renamed_srcs(aTreeArtifactmounted aspkg/)
Why it matters to us
We gate docs freshness on sphinx-build -W, and we're moving builds onto remote
execution, where actions are isolated by default. A docs rule that is only correct
unsandboxed can't be relied on there.
Possible directions (no strong opinion on which is right)
- Materialize the source tree into a real (non-symlinked) directory at action
time before invoking Sphinx — this is what our current hand-rolled genrule does
(stages into atempfile.TemporaryDirectory()), and it's why it works under any
strategy. It presumably conflicts with keeping a stablesrcdiracross
persistent-worker requests. - Pass Sphinx a
srcdirthat is already canonical, so MyST's resolution and
Sphinx's docnames agree. - If this is properly a myst_parser bug rather than a sphinxdocs one, we're happy
to take it upstream there instead — pointers welcome.
Happy to supply a minimal standalone repro repo if useful.
Activity
rickeylev commentedon Jul 30, 2026
Oh interesting! Yes, a minimal repro would be useful.
I wonder if this would explain why it's been hard to fix all the xref errors in our own docs. It seemed like they would appear and disappear randomly, or xrefs were oddly picky in ways I couldn't figure out. I always just chalked it up to the inscrutable xref resolution logic sphinx has.
ctcjab commentedon Jul 31, 2026
Repro below. First, though, an important correction to my original report — I had two cells of that matrix wrong, and the corrected version makes this a bigger problem than I described, not a smaller one.
Correction: it fails in every execution mode, not just sandboxed
My "worker and local pass" rows were action-cache hits that never re-executed Sphinx. When I forced re-execution (perturbing the action key with
--@sphinxdocs//sphinxdocs:extra_env=and confirming from the process line that the spawn actually ran), stock sphinxdocs fails in all three modes:localsandboxedrealpath(srcdir)So sandboxing is not the trigger; it's an additional layer. And I was wrong that copying doesn't help — copying fixes the default and
localcases. Sorry for the noise on both counts.Relevant to your flaky-xref suspicion: because stock fails regardless of strategy, any sphinxdocs user with relative markdown links between pages is affected. What varies is which absolute path shows up in the message, and whether the action re-ran at all — which would look exactly like xrefs "appearing and disappearing randomly."
Diagnosis
MyST resolves a relative link by canonicalizing the current document's path (following symlinks), then resolves the target relative to that. Sphinx then can't map the result to a docname because it no longer shares
srcdir's prefix._sources/x.mdis a symlink into the original source tree, so resolution escapes there. The reported target is a source-tree path._relocatetocpregular files instead ofctx.actions.symlink):_sources/x.mdis a real file, resolution stays inside_sources, and it works — unsandboxed._sources/x.mdis once again a symlink (now pointing at the realbazel-outcopy). Resolution escapes to the real execroot whilesrcdirstays sandbox-prefixed.I also tried canonicalizing
srcdirinsphinx_build.py(args[1] = os.path.realpath(args[1]), verified applied). It does not fix the sandboxed case, because the escape happens per-document rather than via thesrcdirargument.That suggests the only strategy-independent fix is for the srcdir contents to be real files at action time — i.e. materialize/copy the source tree inside the action before invoking Sphinx, rather than assembling it from symlinks beforehand. I realize that likely conflicts with keeping a stable
srcdiracross persistent-worker requests, so I don't have a good proposal that preserves the worker design. Happy to test any patch you want to try.Minimal repro
Two markdown pages linking to each other,
myst_parser,-W. No autoapi, no wheels.MODULE.bazel:BUILD.bazel:docs/conf.py:docs/index.md:docs/other.md:requirements.txtis a compiled lock of just:(
uv pip compile requirements.in -o requirements.txt, or pip-compile — 62 lines, nothing special in it.)Then:
Output (default, worker):
Note the resolved targets are
docs/otheranddocs/indexin the original source tree, not under_sources.One caveat when reproducing: add something to the action key on each run (e.g.
--@sphinxdocs//sphinxdocs:extra_env=RUN=1, incrementing) and check theN processes:line actually shows aworker/linux-sandbox/localspawn. Otherwise you get an action-cache hit and it looks like a mode passed when Sphinx never ran — which is exactly how I got the original matrix wrong.Also worth knowing: in worker mode, Sphinx's stdout/stderr is captured and only surfaced on failure, so with warnings-not-errors these are silent.
[-]sphinxdocs: relative markdown links fail to resolve under sandboxed execution (myst.xref_missing)[/-][+]sphinxdocs: relative markdown links between pages fail to resolve (myst.xref_missing)[/+]sphinxdocs: add reproduction test case for relative markdown xref res…
rickeylev commentedon Aug 3, 2026
Yeah, having to copy everything is unfortunate since its more work. I don't think its incompatible with worker mode, though. I think it just means the worker has to do more work to keep things in sync. It receives the list of input files and hashes, then has to copy those inputs with changed/new hashes to a private srcdir. That doesn't sound too bad.
sphinxdocs: add automated test asserting relative markdown xref resol…
fix(sphinxdocs): include stderr in Sphinx worker response output (baz…
test(sphinxdocs): add test coverage for directory artifact relative x…
sphinxdocs: add reproduction test case for relative markdown xref res…
sphinxdocs: add automated test asserting relative markdown xref resol…
test(sphinxdocs): add test coverage for directory artifact relative x…
rickeylev commentedon Aug 5, 2026
I put out #3977 to fix this. It copies everything, but that was the only solution I could find. Which is too bad -- it'll make the disk usage much worse, plus it takes time to copy everything (it does it as concurrently as possible). Ah well.
rickeylev commentedon Aug 5, 2026
Fixed by #3994
Fixed by #4006
fix(sphinxdocs): fix path resolution for conf.py in separate directory
fix(sphinxdocs): fix path resolution for conf.py in separate directory
fix(sphinxdocs): fix path resolution for conf.py in separate director…
fix(sphinxdocs): fix path resolution for conf.py in separate director…
fix(sphinxdocs): fix path resolution for conf.py in separate director…
fix(sphinxdocs): fix path resolution for conf.py in separate director…
fix(sphinxdocs): fix path resolution for conf.py in separate director…
fix(sphinxdocs): fix path resolution for conf.py in separate director…
fix(sphinxdocs): materialize source symlinks to resolve relative cros…
fix(sphinxdocs): fix path resolution for conf.py in separate directory (