Skip to content

Make the wasm lookup cache, the session status and the main thread id safe for concurrent commands - #195

Open
demrom wants to merge 3 commits into
holzschu:masterfrom
demrom:fix/tsan-races
Open

demrom wants to merge 3 commits into
holzschu:masterfrom
demrom:fix/tsan-races

Conversation

@demrom

@demrom demrom commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Depends on #192; until it lands this diff also shows its commits.

When commands run from several threads, ThreadSanitizer reports three data races, all of them real:

  • executeWebAssemblyCommandsInOrder caches the result of dlsym in a static function pointer that command threads write and read without synchronization. It is now _Atomic, and the lookup still falls back to RTLD_DEFAULT.
  • The session's global_errno is written by every command thread and read by the caller of ios_system. It is now _Atomic int.
  • The session's mainThreadId is written by ios_system when it starts the first command of a session, and read and cleared by cleanup_function on the command thread. It is now _Atomic(pthread_t). The command thread stores its own id in run_function (a new storeMainThread flag), with atomic_compare_exchange_strong only while the field is empty, so a command that ends before ios_system returns is never recorded after it ended. cleanup_function clears it with atomic_compare_exchange_strong only while it still holds its own thread, and the window-resize code loads it once before pthread_kill.

TSan also reports ios_nextAvailablePid and ios_storeThreadId: the first returns with pid_mtx locked and the second unlocks it on the new command thread, which TSan cannot follow, so it reports two threads holding the mutex at once. That locking stays as it is. tests/tsan.supp suppresses the accesses inside that window (ios_nextAvailablePid, storeEnvironment, environmentVariables, resetEnvironment), ios_restoreParentPid, whose compare-and-swap on the release path races with the plain writes in that window, and ios_currentPid. The ios_currentPid entry also hides the unlocked reads of current_pid against the plain writes in ios_nextAvailablePid, which this change leaves alone. tests/README.md explains how to build with ENABLE_THREAD_SANITIZER=YES and run with the file.

Test

tests/concurrent-fork.m, linked with -fsanitize=thread against a TSan build of the framework from a fresh clone of this branch and run with suppressions=tests/tsan.supp on the iOS 27 Simulator:

tests/main-thread-winch.m covers the stale id: a short command ends before ios_system returns, then a long command installs a SIGWINCH handler and the test calls ios_setWindowSize and checks that the handler runs on the long command's thread. On master + #192, 5 of 5 runs fail with SIGWINCH was handled on another thread; with this change 10 of 10 runs pass.

With the other four changes of the series merged, TSan also reports the mainThreadId write ios_system makes after creating the command (in __ios_system_block_invoke) against the clear in cleanup_function. Without the mainThreadId part of this change, concurrent-fork reports it in 5 of 10 runs and concurrent-getopt in 1 of 10, one report each; with it, 0 reports in 10 of 10 runs of each.

On an iPhone, in a-Shell mini 2.2.3 (592) built from a-shell master, with an ios_system built from all five changes of this series merged, 120 pipelines of cat | sort -r -u -n | uniq -c | head -n 3 ran one after another from dash, each with the expected output. The races are timing-dependent, so the Simulator TSan runs above are what show them fixed.

…still owns it

ios_storeThreadId stores a new thread only if thread_ids[current_pid] is -1, and current_pid is a global. A command that ends between ios_fork and that store rewound current_pid, so the new thread checked the wrong slot, skipped the store, and ios_waitpid never returned.

The three release sites now rewind current_pid with a compare-and-swap, only if the ending command is still the current one.

tests/concurrent-fork.m runs eight threads that each fork, run a command and wait for it 400 times. A two-minute alarm turns a hang into a failure and prints the stacks of all threads.
… safe for concurrent commands

ThreadSanitizer reports three races between command threads: the cached dlsym result in executeWebAssemblyCommandsInOrder, the session's global_errno, and the session's mainThreadId. The first two are atomic now. mainThreadId is set and cleared with compare-and-swap and loaded once before pthread_kill.

mainThreadId is recorded on the command thread itself, in run_function, so a command that ends before its starter returns is never recorded after it ended. tests/main-thread-winch.m resizes the window while a long command runs after a short one.

tests/tsan.supp hides the accesses inside the window where ios_storeThreadId unlocks pid_mtx on another thread, which TSan cannot follow. tests/README.md says how to build and run with it.
@demrom
demrom marked this pull request as ready for review October 24, 2026 04:17
@holzschu

holzschu commented Nov 1, 2026

Copy link
Copy Markdown
Owner

Thanks a lot for that one.

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