DEV Community

orca forge
orca forge

Posted on Originally published at forge.workstyle.tech

Why Chromium PDF Conversion Times Out When JavaScript Is Disabled

📝 Originally published (in Japanese) at forge.workstyle.tech.

TL;DR Chromium PDF conversion stopped because JavaScript was disabled with --blink-settings=scriptEnabled=false. marp-cli 4.5.1 waited for requestAnimationFrame, which did not return with scripts disabled. Removing the setting from both the Marp and direct HTML paths restored PDF output in about 4 seconds.

TL;DR

Chromium’s PDF conversion stalled when JavaScript was disabled with --blink-settings=scriptEnabled=false. marp-cli 4.5.1 waits for the result of requestAnimationFrame, but that value was never returned while scripts were disabled. After removing the setting from both Marp and the raw HTML, I verified the fix in real Chromium: the PDF was generated in about four seconds.

I Released After a Full Build Without Checking PDF Conversion

A full build had replaced dependencies that weren’t pinned to specific versions. marp-cli changed from 4.3.1 to 4.5.1, puppeteer-core from 24.42.0 to 24.43.1, and Chromium was now version 154.

Before release, I tested only the authentication endpoints. I confirmed they returned 401 and 403. I didn’t run PDF conversion. I released with the dependencies changed, without verifying the conversion.

At first, I suspected the code that invoked the conversion. Nothing appeared on screen. When I investigated, I found that it called subprocess.run inside an async operation. Because the synchronous call blocked the event loop—the mechanism that advances asynchronous work in order—I couldn’t see the conversion’s progress either. Meanwhile, Liveness—the monitoring check that verifies the service is running—was terminating the container.

“If the process is getting stuck, I just need to fix how the child process is started.” That was my conclusion. At least, it fit part of the symptoms.

Adding a 60-Second Limit to the Child Process Still Results in a 504

At 11:55, I made the first fix. I ran the child process asynchronously and added a 60-second timeout. To make sure any grandchildren it spawned were also cleaned up, I used a subreaper—a mechanism that collects a process’s children.

I also changed how it was invoked: I ran marp directly with --no-stdin --browser-timeout 30. All 228 tests passed.

I expected this to return the PDF. Instead, it still timed out with a 504 after 60 seconds.

Adding a timeout doesn’t necessarily end the operation that’s still waiting. Even after making it possible to stop the child process, I still had to investigate what it was waiting for.

I suspected the network restriction might be the cause. Since Chromium’s outbound traffic was blocked, perhaps that was holding up rendering. I investigated that possibility, but the network restriction wasn’t the cause.

Chromium Was Waiting for requestAnimationFrame to Return

The real cause was a Chromium setting we had added as a security fix:

--blink-settings=scriptEnabled=false

This setting disables scripts in Chromium. After calling setContent, marp-cli 4.5.1 calls page.evaluate(requestAnimationFrame) and waits for requestAnimationFrame to run at the next rendering opportunity. There is no timeout. With scripts disabled, no result is returned, so the conversion stalled there.

I reproduced this with my local Chrome 151 and confirmed the cause. I also confirmed that blocking network access was not the cause.

At this point, the cause was the combination of the application and the Chromium conversion process. We had disabled scripts while waiting for a value returned by a script. Adding a timeout would only have limited how long it waited.

Reproducing PDF Conversion in the Same Sandbox as Production

To reproduce the issue, I didn’t just run the command outside the sandbox. I used the same invocation as the app, including the sandbox wrapper, just as in production. That reproduced the state where PDF conversion stalls.

This was important. A test that launches Chromium on its own can’t account for the settings the app passes in. The fact that a PDF was generated locally doesn’t tell you whether it will work with the actual invocation.

Fixing only the Marp path wasn’t enough. The path that handles HTML directly had the same scriptEnabled=false setting. Also, --print-to-pdf won’t write a PDF when scripts are disabled. I had to fix each place where the problematic setting remained.

PDF generation restored after fixing Marp and direct HTML settings

At 13:27, I made a second fix. I removed scriptEnabled=false from both Marp and direct HTML. I also changed the TMPDIR environment variable (a value set in the runtime environment) so each request uses a separate temporary directory.

I added tests using real Chromium, bringing the total to 236. Network isolation, sanitization, and CSP remain in place. I didn’t remove all the security measures; I removed the setting that was preventing PDF output and tested the result in real Chromium with the remaining measures enabled.

At 14:48, the PDF was generated. It was 253,841 bytes, and conversion took about four seconds.

I haven’t checked whether conversion was also blocked in the previous version. I can record that the dependency version increased and that this incident occurred, but I can’t say the version increase caused the failure. I also don’t know whether anyone used PDFs while the previous version was in place.

After a Full Build, Verify the Actual PDF Conversion

In this release check, I only looked at authentication errors (401/403) and never ran the PDF conversion. I also didn’t test with the production configuration that invokes the conversion.

To prevent this from happening again, I’ve decided to run the actual process at least once after a full build. I’ll also compare versions before and after bundling the dependencies. An endpoint responding and the final artifact being produced need to be verified separately.

Adding a 60-second timeout was necessary. Even so, no PDF was produced. Preventing a process from getting stuck and confirming that it completes correctly under the right conditions are separate tasks.

A command may run outside the sandbox without behaving the same way it does when invoked in production. Next time, I’ll run the actual conversion after a full build. That way, I won’t treat a successful run outside the sandbox as proof that everything works.

Key takeaways

  • Run an actual PDF conversion before release, alongside authentication checks.
  • Reproduce with the application’s invocation and isolation wrapper.
  • Check both Marp and direct HTML paths for --blink-settings=scriptEnabled=false.
  • Compare dependency versions after a full build and verify that a PDF is produced.

Top comments (0)