Enabling languages causes 75+ second document loads

I am a sysadmin and I had a user complain that spell checking wasn’t working in Collabora. I found that this was because allowed_languages in my config was set to an empty string.

Setting it to just en_US enabled spell checking, but now instead of documents loading in a second or two, they take 75 seconds for a nearly empty .odt document. What’s worse is that it gets past the loading screen and just shows the user a blank document, making them think the server lost all their data. However, if they just sit and stare at the screen for 75 seconds, everything will appear and everything seems to work fine.

During this long load time, one core on the server is pegged at 100% for the entire duration.

I installed collaboraoffice-dict-en via apt to get my dictionary. I also installed hunspell. I’ve tried making my locale’s dict_path point to both /opt/collaboraoffice/share/extensions and /usr/share/hunspell with no apparent difference. I also put in an override for my coolwsd.service to allow access to those directories (ReadOnlyPaths=/usr/share/hunspell /opt/collaboraoffice), so this isn’t a permission error (which is also supported by the fact that spell checking works if you tolerate the load time).

My dictionaries are 841Kb (hunspell) and 539Kb (from collaboraoffice-dict-en). Nothing out of the ordinary. I’d expect them to be read and loaded into memory in tens of milliseconds. I’ve seen the warning in the config, and I understand loading more dictionaries takes more time, but the posts I’ve seen on this forum seem to define 1 second as acceptable, 1.5 seconds as getting a little long, and 2.0 seconds to be an unacceptably long time. I’d be delighted to have a 2 second load time at this point. I’d tolerate a 5 second load time if I had to, but going beyond that is where I have to draw the line.

To try to figure out what’s going on, I’ve done straces of all coolfork processes, following all forks. I can see that it’s loading a .dic file that is 551,797 bytes which is pretty close to /opt/collaboraoffice/share/extensions/dict-en/en_US.dic (which is 551,762 bytes). The interesting thing is that if I stop coolwsd, replace that file with a tiny test dictionary, start coolwsd, and re-run the test, I see that exact same size dictionary. In both cases it’s reading /lo/program/../share/wordbook/en_US.dic and I’m not sure where that’s coming from. In there’s an en-US.dic in /opt/collaboraoffice/share/extensions/dict-en but no en_US.dic there.

Also worth noting is that I never see any .aff files loaded. I’d expect that by following all forks, it’d show up somewhere in my strace data, but it’s possible I missed it. It’s also possible that this file isn’t really needed and the fact that it’s not loading is expected behavior. In any case, I wanted to mention it in case it’s a clue.

Digging through the strace data for the process which loads the document and the dictionary, it basically looks like this:

  1. chroot stuff
  2. bind-mounts
  3. opens the en_US.dic and reads only the first 4Kb
  4. copies some presets
  5. reads standard.dic (45 bytes)
  6. opens en_US.dic again and only reads the first 4Kb
  7. loads the document (test.odt, 13,388 bytes)
  8. does NSS stuff
  9. opens en_US.dic and reads the entire thing in 4KB chunks. The reads are instant, but this is where 70 seconds of CPU time is spent (in userspace, not waiting on syscalls).
  10. standard.dic is opened and read again
  11. font loading

The inability to replace the dictionary file is limiting my ability to test with a small dictionary to see how long that takes. I tried enabling en_GB and de_DE (which I installed for testing purposes). This causes documents to take 120 seconds to load. So it’s not triple the duration, but it does make a meaningful difference.

I also briefly tried a command line option that would disable jails. It didn’t seem to make a difference, so I removed it.

My setup is operating with Nextcloud, specifically the richdocuments app integration. I’m currently on Collaboraoffice 25.04.11-3, the latest available from apt at the time of writing.

The bigest question I have is: how do I continue troubleshooting this issue from here?

Specifically, is there some way I can replace the dictionary file that is going to be used in practice so I can figure out if a tiny dictionary loads faster, or if the dictionary needs to be in a different format?

And why am I the only person who seems to be having this problem? This feels like it’s a dictionary parsing issue, but I know there are lots of people who run the Nextcloud/Collabora combo, and I have to imagine that at least some of them turn on spell checking. I also expect the number of them who would accept a load time of 60+ seconds per document to be exactly zero.

But the “slow load times” I saw people taking about were 2 seconds, which is nothing like what I’m experiencing. If anyone could post a comment here if you have languages/spell checking enabled and it’s working fine, that’d be appreciated. If someone can attempt to re-create this issue on a test server, that’d be double appreciated. I’m using this Ansible role to set up my server on a Debian 13 VM. If I disable all languages (which is the role’s default), it works beautifully. Enable any language and it gets me here.

The way I’m collecting strace data is basically this:

# Get the list of coolfork pids and run strace on each one
pids=`ps -ef | grep [c]oolfork | awk '{ print $2 }'`
for p in $pids; do strace -ff -o /tmp/coolwsd_traces/ -p $p & done

# Click on the document in the Nextcloud web UI, wait for it to completely load

# Gracefully kill each of the strace processes
pids=`ps -ef | grep [s]trace | awk '{ print $2 }'`
for p in $pids; do kill $p; done

If someone is willing to go through the thousands of lines of the strace files, I’ll post them. Same goes for my XML config, even through I went over all the settings that seem relevant.

This one is a real head scratcher, that’s for sure! Any help would be appreciated. I’ve already spent 9 hours over the course of 3 days trying to figure this one out. I want that effort to result in a bug getting fixed, a features added, or some documentation to warn people about this. In the meantime, I’m leaving spell checking off so Collabora is usable.

Edit to add: I’m not using Docker or any other container technology. So this isn’t an issue of Docker getting in the way. I am using ProtectSystem=strict in the .service file and the chroot that is implemented with Collabora is in place, but both of those are default settings.

hey @hackin_adam

Welcome to the Collabora Online Forum! I must say, this is a very detailed report—thanks a lot for taking the time to put it together.

Here are my suggestions:

The 75 seconds is not hunspell. Your strace path /lo/program/../share/wordbook/en_US.dic is /opt/collaboraoffice/share/wordbook/en_US.dic on the host - and share/wordbook/ is not where hunspell dictionaries go. It holds “custom dictionaries” (the word lists behind “Add to dictionary”), which are normally tiny. Something - almost certainly your Ansible role’s dictionary task - converted the 550KB hunspell word list into a wordbook and installed it there, one per language in allowed_languages.

Fix:

ls -la /opt/collaboraoffice/share/wordbook/

Delete the big en_US.dic (and en_GB/de_DE) from that directory, keep the small stock files, restart coolwsd. Leave allowed_languages set to en_US. Spell checking then comes from the real hunspell dictionary in /opt/collaboraoffice/share/extensions/dict-en/ that collaboraoffice-dict-en installed - no dict_path or systemd overrides needed. Loads should drop back to 1-2 seconds.

Why it was slow: the wordbook loader in the engine re-checks the whole list after every inserted word (a debug check that is compiled into these Linux builds), so a 50,000-word file costs over a billion string comparisons - your pegged core. It also explains your other observations: your replacement test changed the wrong file (share/extensions/, not share/wordbook/), no .aff was ever read because wordbooks have none, and spell checking still “worked” because a wordbook full of English words accepts them all.

Nobody else sees this because a stock install never has a large file in share/wordbook/. Report it against the Ansible role: enabling spell checking should only need allowed_languages plus the dictionary package.

The en_{US,GB}.dic files were in /opt/collaboraoffice/share/wordbook/ as you suggested, but they were only 441 bytes (46 lines). I removed the dict_path from my config, moved the small dictionaries out of the way and restarted collabora, but it didn’t make a performance difference.

I suspect your explanation as to why it’s slow (1B+ string comparisons) is probably correct and I just need to figure out where the dictionaries are coming from and why. And I’ve made some progress on that front. Time for another deep dive…

According to strace, the dictionary that’s being read is 551806 bytes:

openat(AT_FDCWD, "/lo/program/../share/wordbook", O_RDONLY|O_NONBLOCK|O_CLOEXEC|O_DIRECTORY) = 14
fstat(14, {st_mode=S_IFDIR|0755, st_size=4096, ...}) = 0
getdents64(14, 0x33d62f50 /* 3 entries */, 32768) = 80
lstat("/lo/program/../share/wordbook/en_US.dic", {st_mode=S_IFREG|0644, st_size=551806, ...}) = 0

And this file size is consistent throughout the entire strace. Here’s the lstat calls for all dictionary files.

grep lstat .30591 | grep dic
lstat("/lo/program/../share/wordbook/en_US.dic", {st_mode=S_IFREG|0644, st_size=551806, ...}) = 0
lstat("/lo/program/../share/wordbook/en_US.dic", {st_mode=S_IFREG|0644, st_size=551806, ...}) = 0
lstat("/lo/program/../share/wordbook/en_US.dic", {st_mode=S_IFREG|0644, st_size=551806, ...}) = 0
lstat("/tmp/user/user/wordbook/standard.dic", {st_mode=S_IFREG|0644, st_size=45, ...}) = 0
lstat("/tmp/user/user/wordbook/standard.dic", {st_mode=S_IFREG|0644, st_size=45, ...}) = 0
lstat("/tmp/user/user/wordbook/standard.dic", {st_mode=S_IFREG|0644, st_size=45, ...}) = 0
lstat("/lo/program/../share/wordbook/en_US.dic", {st_mode=S_IFREG|0644, st_size=551806, ...}) = 0
lstat("/lo/program/../share/wordbook/en_US.dic", {st_mode=S_IFREG|0644, st_size=551806, ...}) = 0
lstat("/lo/program/../share/wordbook/en_US.dic", {st_mode=S_IFREG|0644, st_size=551806, ...}) = 0
lstat("/tmp/user/user/wordbook/standard.dic", {st_mode=S_IFREG|0644, st_size=45, ...}) = 0

Side note: the standard.dic seen above appears to be coming from /opt/cool/cache/ and it is an empty dictionary, having only the header (which is why it’s only 45 bytes). The path seems to indicate that this is my user-specific custom dictionary which came from the Nextcloud server.

But that file size for en_US.dic does not match up at all with what’s in /opt/collaboraoffice/share/wordbook/ (I put the small files back after running my test).

ls -la /opt/collaboraoffice/share/wordbook/
total 20
drwxr-xr-x  2 root root 4096 Jul  8 10:15 .
drwxr-xr-x 29 root root 4096 Jul  2 17:02 ..
-rw-r--r--  1 root root  441 Jun 14 17:32 en-GB.dic 
-rw-r--r--  1 root root  441 Jun 14 17:32 en-US.dic
-rw-r--r--  1 root root 2804 Jun 14 17:25 technical.dic

The file size seen in strace almost matches up with the dictionary found in /opt/collaboraoffice/share/extensions/dict-en/

ls -l /opt/collaboraoffice/share/extensions/dict-en/en_US.dic
-rw-r--r-- 1 root root 551762 Jun 14 17:32 /opt/collaboraoffice/share/extensions/dict-en/en_US.dic

However, the strace data shows that /opt/collaboraoffice was overridden by a mount point.

mount("/opt/collaboraoffice", "/opt/cool/child-roots/30551-8e4abf70/l1s6HHGMp1Dz1vrg/lo", NULL, MS_MGC_VAL|MS_BIND|MS_REC, NULL) = 0
...
mount("/opt/collaboraoffice", "/opt/cool/child-roots/30551-8e4abf70/l1s6HHGMp1Dz1vrg/lo", 0xb6c3ba, MS_RDONLY|MS_NOSUID|MS_NODEV|MS_REMOUNT|MS_BIND, NULL) = 0

And the file size does match up exactly with a file on disk under /opt/cool/child-roots/

ls -l /opt/cool/child-roots/30551-8e4abf70/tmp/sharedpresets/shared-https_nextcloud.ucimc.org%2Findex.php%2Fapps%2Frichdocuments%2Fwopi%2Fsettings-6a45ec7bdf7aa696d89c3cf3f4/wordbook/en_US.dic
-rw-r--r-- 1 cool cool 551806 Jul  8 10:35 /opt/cool/child-roots/30551-8e4abf70/tmp/sharedpresets/shared-https_nextcloud.ucimc.org%2Findex.php%2Fapps%2Frichdocuments%2Fwopi%2Fsettings-6a45ec7bdf7aa696d89c3cf3f4/wordbook/en_US.dic

The latter dictionary appears to be the same as the former, only with a header added.

head /opt/collaboraoffice/share/extensions/dict-en/en_US.dic
49568
0/nm
0th/pt
1/n1
1st/p
1th/tc
2/nm
2nd/p
2th/tc
3/nm

vs

head /opt/cool/child-roots/30551-8e4abf70/tmp/sharedpresets/shared-https_nextcloud.ucimc.org%2Findex.php%2Fapps%2Frichdocuments%2Fwopi%2Fsettings-6a45ec7bdf7aa696d89c3cf3f4/wordbook/en_US.dic
OOoUserDict1
lang: <none>
type: positive
---
49568
0/nm
0th/pt
1/n1
1st/p
1th/tc

I feel like this is substantial progress, even though it’s not yet solved. I really appreciate the succinct answers on things like why the .aff files weren’t being read, why the CPU was pegged, etc.

Because I found the mount points so confusing, I decided to disable the jail and get a trace of that. My document loaded in the expected <= 2 seconds and spell checking was working! I don’t understand why, and I don’t really want to run it without a chroot, but I’m very excited to have stumbled upon this fact. My straces didn’t capture as much data in this configuration. Apparently this changes the interprocess communications in a way that only gets me a few dozen lines of strace output (no calls to lstat nor open, just a bunch of polling). That’s not what I expected, but it’s also not terribly surprising.

To be clear, I was only disabling the jail in coolwsd.xml. The restrictions from systemd are still in place.

If you have any pointers to source code for me to take a look at, I’m familiar with C++ (or at least I was until they started adding all sorts of wild new stuff over the past couple decades :laughing:). I’m also willing to put a custom build of coolforkit-ns on the server temporarily to do additional debugging if necessary.

Once I figure this out, the Ansible role will be immediately updated in whatever way is needed, that’s for sure! I plan on destroying and re-building the entire Collabora VM to test it too, because I’ve done a ton of things manually on this VM trying to troubleshoot this issue.