Skip to content

Avoid recursive cache lock in SDL TTF ports - #27590

Open
brendandahl wants to merge 1 commit into
emscripten-core:mainfrom
brendandahl:sdl-cache
Open

Avoid recursive cache lock in SDL TTF ports#27590
brendandahl wants to merge 1 commit into
emscripten-core:mainfrom
brendandahl:sdl-cache

Conversation

@brendandahl

Copy link
Copy Markdown
Collaborator

When building port libraries, build_port compiles sources with emcc in a subprocess while the parent process holds EM_CACHE_IS_LOCKED. Passing -sUSE_* flags causes the child emcc to resolve ports and attempt to acquire the cache lock if dependencies like harfbuzz are not yet in the cache, causing an AssertionError.

Pass include directories directly instead of port settings flags when compiling SDL TTF port sources. Also ensure parent directories exist when writing port assets.

When building port libraries, build_port compiles sources with emcc in a
subprocess while the parent process holds EM_CACHE_IS_LOCKED. Passing
-sUSE_* flags causes the child emcc to resolve ports and attempt to
acquire the cache lock if dependencies like harfbuzz are not yet in the
cache, causing an AssertionError.

Pass include directories directly instead of port settings flags when
compiling SDL TTF port sources. Also ensure parent directories exist
when writing port assets.

@sbc100 sbc100 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like you are trying to fix this issue: #27160

Even though this change might fix the issue I don't think it should be necessary, because all ports always build their dependencies before building themselves.

Comment thread test/test_other.py
self.set_setting('USE_SDL', 2)
self.set_setting('USE_SDL_TTF', 2)
with shared.cache.lock('test_sdl_ttf_parent_cache_lock'):
ports.get_port_by_name('sdl2_ttf').get(ports.Ports, settings, shared)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd rather find a way to test this from outside using embuilder and/or emcc rather then using the internal API like this.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see a way to deterministically test this. Do you have any suggestions?

@sbc100

sbc100 commented Aug 22, 2026

Copy link
Copy Markdown
Collaborator

In other words, the assertion in question should never fire. At least when when N process are all trying to build the same ports.

The only time this assertion should ever fire is if another process deletes a ports right after the current process builds it. But being robust against the deletion of ports is not something I think we can ever really be robust against, and deleting ports while other folks are using the compiler seems like a situation that lead to crash like this.

The question is, how is it possible that the second process is deleting/clearing libsdl2 here:

Ports.clear_project_build(name)
.. after anther process just built it?

@brendandahl

Copy link
Copy Markdown
Collaborator Author

My understanding with two workers:

  1. Worker 1 unpacks harfbuzz
  2. Worker 2 blocks
  3. Worker 1 finishes
  4. Worker 2 unblocks and runs up_to_date. up_to_date returns false so then Worker 2 deletes harfbuzz
  5. Worker 1 tries to build and crashes

The why up_to_date fails is a bit of a mystery to me. Gemini says "On Windows (or under high I/O concurrency), if marker (.emscripten_url) cannot be read cleanly (e.g. file sharing/locking contention, non-atomic directory rename operations, or line-ending mismatches with strip()), up_to_date() evaluates to False."

@sbc100

sbc100 commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator

My understanding with two workers:

  1. Worker 1 unpacks harfbuzz
  2. Worker 2 blocks
  3. Worker 1 finishes
  4. Worker 2 unblocks and runs up_to_date. up_to_date returns false so then Worker 2 deletes harfbuzz
  5. Worker 1 tries to build and crashes

The why up_to_date fails is a bit of a mystery to me. Gemini says "On Windows (or under high I/O concurrency), if marker (.emscripten_url) cannot be read cleanly (e.g. file sharing/locking contention, non-atomic directory rename operations, or line-ending mismatches with strip()), up_to_date() evaluates to False."

Yes, I think that analysis matches the on in #27160 (comment) ?

I also don't see how its possible for up_to_date to return false after worker 2 is unblocked... it would mean that worker 2 was able to observe the FS lock file being released but not the port build being complete even though worker 1 didn't unlock the cache until after it finished the build.

The logical conclusion seems to that we cannot use filesystem locks files to enforce sequential consistency between processes.. which I find hard to believe, and also very worrying since I don't know how else to solve this problem if we cannot depend on the lock files for synchronization like this.

@sbc100

sbc100 commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator

This change works around the issue, but its doesn't solve it. Maybe that is good enough for now?

@sbc100

sbc100 commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator

I think this issue has been seen now on mac, linux and windows bots, but I've never been able to reproduce it locally, even using taskset + massive parallelism.

Have you had any luck reproing locally?

@brendandahl

Copy link
Copy Markdown
Collaborator Author

The logical conclusion seems to that we cannot use filesystem locks files to enforce sequential consistency between processes.. which I find hard to believe, and also very worrying since I don't know how else to solve this problem if we cannot depend on the lock files for synchronization like this.

I don't think the issue is filesystem locks. It's that we have multiple locks that can be interleaved and there are state checks outside of locks. I think we can fix this by moving some of the locks to cover more of the process or avoid trying to lock in subprocesses (what this PR does and not use -sUSE_).

@sbc100

sbc100 commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator

The logical conclusion seems to that we cannot use filesystem locks files to enforce sequential consistency between processes.. which I find hard to believe, and also very worrying since I don't know how else to solve this problem if we cannot depend on the lock files for synchronization like this.

I don't think the issue is filesystem locks. It's that we have multiple locks that can be interleaved and there are state checks outside of locks. I think we can fix this by moving some of the locks to cover more of the process or avoid trying to lock in subprocesses (what this PR does and not use -sUSE_).

The logic of the ports system should already guarantee that no locks are ever needed during subprocess.

That we why we have the EM_CACHE_IS_LOCKED assertion in the first place. If you need to take a lock in a subprocess then the parent process fails to do its job of building all your dependencies first.

  1. Process A hold a lock while it build library L
  2. Process B tries to grab library L but is blocked until Process A releases the lock
  3. Once process A is finshed building library L and releases the lock.
  4. Process B can now acquire the lock and should see the library build as complete <- BUG HERE

I believe all the analysis (both yours, and mine, and geminis) points to some kind of bug at phase 4 where process B gets the lock but does not see the library as stamp file that process A wrote. This should be impossible because the stamp file was written (and the file closed) by process A before it released the FS lock.

@sbc100

sbc100 commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator

I'm also not convinced this patch would actually the fix the issue, but maybe just move it around.

The real issue here is that after Process A is done building a library, another parallel Process B sees it as not up-to-date and deletes it.

This means that all the child processes of Process A could be run without the library present (since it was delete by process B). Maybe this doesn't matter as much because then Process B deletes the library I think it just deletes the .a archive and leaves the headers.. but if we fixed that glitch and made it also clean the headers and then the children of Process A would also not be able to see the headers and they would all fail.

In other words I think this change might mask the issue, but I don't think it solves it.

The real issue is that sibling processes are deleting libraries that the other one just built..

@brendandahl

Copy link
Copy Markdown
Collaborator Author

I think this issue has been seen now on mac, linux and windows bots, but I've never been able to reproduce it locally, even using taskset + massive parallelism.

Have you had any luck reproing locally?

The only way I've been able to reproduce is adding sleeps and some special bypasses:

brendandahl@89eb21e

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants