mathjax / mathjax/MathJax

[v4.1.3] Synchronous dynamic font loading causes infinite recursion with multiple SVG output jax instances

Open
#3,597 1 comment 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Accepted Code Example Merged v4
Dominant language
JavaScript
Stars
10.9k
Forks
1.2k
PR merge metrics
No merged PRs in 30d

Description

Replace the text below with the details of the issue you are facing.
DO NOT simply erase the form and type a free-form response.

Issue Summary

MathJax 4.1.3 can produce Maximum call stack size exceeded when multiple documents are typeset sequentially using new SVG output jax instances and synchronous dynamic font loading.

The first document renders successfully. A later document containing dynamically loaded glyphs produces an <mjx-container> with:

data-mjx-error="Maximum call stack size exceeded"

This occurs in a server-side Node.js environment, without a browser. It reproduces on macOS and in GitHub Actions on Ubuntu and Windows.

The same example works with MathJax 4.1.2, so this appears to be a regression introduced by MathJax-src#1500.

Steps to Reproduce:
  1. Install the affected packages:

    npm install @mathjax/src@4.1.3 @mathjax/mathjax-newcm-font@4.1.3
    
  2. Save the following as reproduce.js:

    const path = require('node:path');
    const { mathjax } = require('@mathjax/src/js/mathjax.js');
    const { TeX } = require('@mathjax/src/js/input/tex.js');
    const { SVG } = require('@mathjax/src/js/output/svg.js');
    const { LiteAdaptor } =
      require('@mathjax/src/js/adaptors/liteAdaptor.js');
    const { RegisterHTMLHandler } =
      require('@mathjax/src/js/handlers/html.js');
    
    require('@mathjax/src/js/util/asyncLoad/node.js');
    
    const { MathJaxNewcmFont } =
      require('@mathjax/mathjax-newcm-font/cjs/svg.js');
    
    const adaptor = new LiteAdaptor();
    RegisterHTMLHandler(adaptor);
    
    const dynamicPrefix = path.join(
      path.dirname(
        require.resolve('@mathjax/mathjax-newcm-font/package.json')
      ),
      'cjs/svg/dynamic'
    );
    
    async function typeset(content) {
      const tex = new TeX({
        packages: ['base'],
        inlineMath: [['$', '$']]
      });
      const svg = new SVG({
        fontCache: 'none',
        fontData: MathJaxNewcmFont,
        dynamicPrefix
      });
    
      svg.font.loadDynamicFilesSync();
    
      const document = mathjax.document(content, {
        InputJax: tex,
        OutputJax: svg
      });
    
      await document.renderPromise();
      return adaptor.innerHTML(adaptor.body(document.document));
    }
    
    (async () => {
      await typeset('$E=mc^2$');
    
      const output = await typeset(
        '$\\mathbb{R} \\quad \\mathcal{L}$'
      );
    
      console.log(
        output.match(/data-mjx-error="([^"]+)/)?.[1] ?? 'no error'
      );
    })();
    
  3. Run:

    node reproduce.js
    
  4. With MathJax 4.1.3, the output is:

    Maximum call stack size exceeded
    
  5. Downgrade both packages to 4.1.2 and run the same script. The output is:

    no error
    

I consider this a bug because dynamically loaded font data appears to be shared between font classes, while its setup is only performed for the first font instance. Creating a new SVG output jax for another document should continue to work.

The synchronous loading path should load each dynamic module only once, but make its data available to every new font instance.

Technical details:
  • MathJax Version: 4.1.3
  • @mathjax/mathjax-newcm-font Version: 4.1.3
  • Client OS: macOS 15.7.3; also reproduced in GitHub Actions on Ubuntu and Windows
  • Browser: Not applicable, this is server-side Node.js
  • Node.js: 26.5.0 locally; also reproduced with Node.js 24 in GitHub Actions
  • Module format: CommonJS

I am using the following MathJax configuration:

const tex = new TeX({
  packages: ['base'],
  inlineMath: [['$', '$']]
});

const svg = new SVG({
  fontCache: 'none',
  fontData: MathJaxNewcmFont,
  dynamicPrefix
});

svg.font.loadDynamicFilesSync();

MathJax is loaded through its Node.js CommonJS modules rather than a browser script tag:

const { mathjax } = require('@mathjax/src/js/mathjax.js');
const { TeX } = require('@mathjax/src/js/input/tex.js');
const { SVG } = require('@mathjax/src/js/output/svg.js');

require('@mathjax/src/js/util/asyncLoad/node.js');
Supporting information:
  • A browser example and screenshot are not applicable because the problem occurs during server-side SVG generation.
  • The regression is visible in next-theme/hexo-filter-mathjax#89.
  • DynamicFile objects and their promise fields are shared through the font class's static dynamicFiles collection.
  • loadDynamicFileSync() calls dynamic.setup(this) only when dynamic.promise has not already been created.
  • A second font instance therefore sees the existing promise but does not receive the dynamic character data.
  • The synchronous branch added to getChar() in 4.1.3 calls getChar() recursively while the character remains unresolved, eventually exhausting the call stack.
  • The asynchronous loadDynamicFile() path does not have the same problem because it runs dynamic.setup(this) for each font instance after the shared promise resolves.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with the SVG font-loading entry points loadDynamicFilesSync(), loadDynamicFileSync(), and getChar(), using the supplied reproduce.js script with multiple SVG output jax instances. Compare the synchronous path with the asynchronous loadDynamicFile() behavior and the 4.1.2 result. Done means the second typeset call loads dynamic glyphs without recursive stack overflow and prints no error.

Written by the indexing model from the issue text.

Assessment

Tech stack
javascript, nodejs
Domain
backend, computer-graphics
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Quiet
Clarity
Clearly specified
Newbie friendliness
68/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.