Skip to content

docs: full Sphinx build hangs in typing.get_type_hints() while documenting pcapkit/const/reg #744

Closed

Description

@JarryShaw

A full-tree Sphinx build hangs indefinitely while documenting pcapkit/const/reg, on the repo venv (Python 3.14.7, Sphinx 9.1.0).

  • Confirmed CPU-bound, not a memory leak.
  • Hangs inside typing.get_type_hints(), called from autodoc.
  • Reproduces on both -b dummy and -b html.
  • Reaches ~32% of reading sources, so it is past most of the tree.

Workaround that gets a build through: -D exclude_patterns=pcapkit/const/reg.rst.

Why this matters beyond the inconvenience

It invalidates the warning-count baseline every changelog round has been measured against. #657's rounds compared against build succeeded, 53 warnings (raw grep -c 55, the two extras being the Scapy content lines use_2_bytes_asn: True and CARP overwrites VRRP !). With reg.rst excluded the same head measures 36 real / 38 raw — a different scope, so the two numbers are not comparable. Any future "no new warnings" check needs either this fixed or the exclusion applied consistently on both sides.

Not yet established

Found while running round 9 of #657. Worth knowing that gh-pages deploys from a docs build, so if this reproduces in CI the docs site is affected too — I have not checked whether the CI build hits it, since CI uses a different install path.

Activity

  1. added
    bugIssues reporting a defect (set by the bug report template; a default, not an assessment)
    docsPull requests that change documentation only (docs: subject prefix)
    on Sep 24, 2026
  2. JarryShaw commented on Sep 24, 2026

    @JarryShaw
    OwnerAuthor

    All three questions answered — and this issue's premise is wrong

    It does not hang. It completes. AppType alone builds in 666s (11m6s), exit 0, build succeeded;
    an earlier 240s timeout is what made it look unbounded. On CI the same stall is 6m18.8s. The title
    and body should say "stalls ~6-11 minutes", not "hangs indefinitely".

    1. It is AppType specifically, not size. apptype.py:133 — reserved_0: 'AppType' = 0, 'reserved', … — makes every one of the 8,182 members an annotated assignment, so AppType.__annotations__ holds
    8,183 forward-ref strings that get_type_hints() re-resolves each time autodoc touches the class.
    Controls that refute "size alone": TransType (156 members) ~4s; reg.rst minus only AppType ~5s;
    a synthetic 8,182-member enum without that annotation shape ~18s. CPU-bound throughout (PID sampled
    STAT=R, 100% CPU at 21s / 1m58 / 6m40).

    2. Neither Python 3.14 nor Sphinx 9.1.0 is the trigger — it is structural.

    combo wall-clock
    py3.14.7 + Sphinx 9.1.0 661s
    py3.12.14 + Sphinx 9.1.0 554s
    py3.14.7 + Sphinx 8.2.3 676s
    py3.14.7 + 9.1.0, no sphinx_autodoc_typehints 645s

    Core sphinx.ext.autodoc alone reproduces it. Caveat: one run per combo, so 645/668/676 are noise,
    not a ranking.

    3. CI takes the identical path and succeeds. deploy-pages.yml is the only docs build: python-version: '3.14' (:93), docs/requirements.txt pins Sphinx==9.1.0, exclude_patterns = [] (conf.py:169), no
    -D flag, no timeout-minutes. Run 36027311485 (9813aa377): job 16:26:00→16:34:10, success,
    with a 6m18.8s gap between [31%] pcapkit/const/pcapng and [32%] pcapkit/const/reg. Recent runs are
    green; the cancelled ones are concurrency cancellations superseded within 3-4 min. gh-pages is current.

    Relevance to #732, and this is my inference rather than measured: the per-transport split does not
    reduce the annotation total — it distributes 8,182 across four classes. If the cost is per-class
    get_type_hints() work it may improve; if it is per-annotation it will not. Worth measuring on that PR
    rather than assuming the split fixes this.

    Two by-products. conf.py:19 imports the private sphinx_autodoc_typehints._resolver, which
    hard-pins the docs build to Sphinx>=9.1 — no release satisfies both that import and Sphinx<9. Untracked;
    I'll file it separately if you want. And unexplained: peak RSS 1.9 GB on Sphinx 8.2.3 against ~139 MB
    on 9.1.0 for the same work.

  3. added
    blockedDeferred pending another issue or decision; see the last comment for what unblocks it
    on Sep 24, 2026
  4. JarryShaw commented on Sep 24, 2026

    @JarryShaw
    OwnerAuthor

    Measured: the #732 split makes this worse, not better — 680.3 s → 759.1 s (1.12×)

    The open question here was whether splitting AppType into four per-transport classes would reduce the
    get_type_hints() cost or merely redistribute it. Measured on the split branch (#754): 680.3 s before,
    759.1 s after — 11.6% slower.
    The harness is faithful: its 680.3 s reproduces this issue's 666 s baseline.

    So the annotation total is redistributed across four classes and the per-class overhead is additive rather
    than amortised. The split is not a mitigation for this issue, and anyone expecting it to be one — I
    did — should stop.

    list-table rendering is unaffected: 704/366/122/2/1 rows emit correctly, and reST warnings are identical
    either side (6 before, 6 after).

    That leaves this issue needing a mitigation on its own terms rather than as a by-product. The candidates
    from the earlier investigation are unchanged, and none has been tried: an autodoc option that skips
    per-member annotation resolution, a hand-written .. py:class:: stub for the generated registries, or
    accepting the 6-11 minute stall as the cost of documenting 8,182 annotated members. Note CI already
    absorbs it — deploy-pages.yml has no timeout-minutes and the run goes green — so this is build-time
    cost, not breakage.

  5. removed
    blockedDeferred pending another issue or decision; see the last comment for what unblocks it
    on Sep 24, 2026
  6. JarryShaw commented on Sep 25, 2026

    @JarryShaw
    OwnerAuthor

    Two numbers in the body need correcting, re-measured on CI.

    The 666 s is local. On CI (run 36027311485, success) the whole Install and Build step is
    478 s including pip install, and exactly one gap over 20 s exists in the entire build:

    378.8s   after: reading sources... [ 31%] pcapkit/const/pcapng
            before: reading sources... [ 32%] pcapkit/const/reg
    

    So AppType is the sole stall — nothing else in the build costs even 20 s — at 378.8 s, not
    666 s. deploy-pages.yml is the only Sphinx build in the repo and carries no timeout-minutes
    on any job or step, which is why it absorbs this and goes green.

    Mechanism, settled by call count rather than inference. Sphinx 9.1.0's default autodoc calls
    get_type_hints(parent, ...) once per member (_dynamic/_loader.py:367 and :419), re-resolving
    the whole class's annotations every time. Instrumented on a synthetic reproduction at N=2000:

    variant get_type_hints calls elapsed
    default engine 2000 42.0 s
    autodoc_typehints='none' alone 2000 43.3 s
    legacy engine + autodoc_typehints='none' 0 4.4 s

    autodoc_typehints is never consulted on that path, so setting it to none alone is a dead end.
    Marginal cost is 10.0 / 38.0 / 157.2 s at N=1000/2000/4000 — exponent ≈1.97, i.e. quadratic.

    Recommendation: drop : {self.NAME!r} from pcapkit/vendor/reg/apptype/apptype.py:717 and
    regenerate. The annotation is not load-bearing — member sets are identical with it stripped,
    __annotations__ collapses 13 → 3, mypy reports the same 96 errors in 33 files, and nothing under
    const/reg/ reads __annotations__. Removing it also makes the four
    # mypy: disable-error-code=assignment suppressions redundant. Not yet confirmed by a real build,
    and the strip test ran at 10-member scale, not 6,147.

    The alternative that does fully work — legacy engine plus autodoc_typehints='none' — is rejected on
    cost: it suppresses annotations on 3,390 of 3,390 functions/methods site-wide to fix a defect 80%
    concentrated in two generated files. Separately, deploy-pages.yml wants a timeout-minutes
    regardless of which fix lands; that hole is the more urgent half.

  7. JarryShaw commented on Sep 25, 2026

    @JarryShaw
    OwnerAuthor

    Addendum — a fourth candidate surfaced after the comment above, and it is cheaper than the recommendation. Adding it rather than leaving the ranking looking exhaustive.

    Narrow bind_type_checking_names to skip pcapkit.const.reg.apptype.*. docs/source/conf.py:231 walks every pcapkit module on builder-inited and executes its if TYPE_CHECKING: block. That hook is the necessary condition for the stall: it is what makes the per-member forward refs resolvable, and resolving them is the quadratic part. Its own docstring already describes the alternative — without the binding, typing.get_type_hints() raises NameError for the whole class and Sphinx catches it:

    # sphinx/util/typing.py:203-209
    try:
        return typing.get_type_hints(obj, globalns, localns, include_extras=include_extras)
    except NameError:
        # Failed to evaluate ForwardRef (maybe TYPE_CHECKING)
        return safe_getattr(obj, '__annotations__', {})

    A raw dict read instead of 6,147 forward-ref resolutions per class. Zero generated churn, every member kept, one predicate added to one hook.

    It is unmeasured — stated plainly rather than folded into the ranking. Nobody has run it.

    It still ranks below the generator fix, on merits rather than speed. Narrowing the hook keeps the annotation, which is : 'TCP' on a member of class TCP — tautological — and keeps the four # mypy: disable-error-code=assignment pragmas that annotation is what forces. It also degrades those members' rendered types to unresolved strings, which is the exact class of problem bind_type_checking_names was added to fix. So it treats the symptom in the docs build while leaving the cause in the generated source.

    One corroboration of the O(members²) model, from #754 itself. The split changed member counts such that a sum-of-squares model predicts a 1.13x slowdown (66.9M → 75.5M resolutions); measured was 1.12x (680.3s → 759.1s). That is independent of the synthetic curve and lands within 1%, so the growth model is not an artefact of the reproduction.

    Correction to my own accounting above. I said no real build had been run. No run touched the real docs/source tree, which is true, but roughly 1,954s (~33 min) of compute did execute against the real conf.py via synthetic single-page sourcedirs using -c — more wall-clock than two full builds. "Budget untouched" was the wrong framing and I am withdrawing it.

  8. added
    wipWork in flight - a covering PR is open or an agent is actively on it
    on Sep 25, 2026
  9. added a commit that references this issue on Sep 25, 2026
    9ae0e29
  10. removed
    wipWork in flight - a covering PR is open or an agent is actively on it
    on Sep 25, 2026
  11. added this to the 1.5 milestone on Oct 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugIssues reporting a defect (set by the bug report template; a default, not an assessment)docsPull requests that change documentation only (docs: subject prefix)

    Projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions