Skip to content

sync: prevent RWMutex deadlocks with queued readers - #5630

Closed
yohimik wants to merge 1 commit into
tinygo-org:devfrom
yohimik:upstream-pr/sync-rwmutex
Closed

yohimik wants to merge 1 commit into
tinygo-org:devfrom
yohimik:upstream-pr/sync-rwmutex

Conversation

@yohimik

@yohimik yohimik commented Aug 30, 2026

Copy link
Copy Markdown

Superseded by #5697. The problem is tracked in #5692.

Closed on 17 September 2026 with a request for an issue first. The replacement uses the same patch, rebased on 18 September onto dev at 93940cb6. Its description contains the scope, dependencies, and test status.

shibukawa added a commit to shibukawa/tinygodriver that referenced this pull request Sep 2, 2026
…on the TinyGo path

TinyGo's sync.RWMutex (through at least 0.42) deadlocks whenever a reader
arrives while a writer is waiting for the existing readers to drain. Lock()
subtracts rwMutexMaxReaders and waits for the last RUnlock to bring the count
back to exactly that; RLock() adds 1 and then waits for the count to turn
positive, keeping its +1 while it waits. A reader that arrives mid-wait is
therefore counted as a holder that never leaves: the writer is never woken and
the reader waits for the writer's Unlock. Standard Go snapshots the readers a
writer must wait for in a separate counter. Upstream fix: tinygo-org/tinygo#5630,
open at time of writing.

This is what `tinygo test ./websocket` had been hanging on, roughly one run in
three: a stack sample showed 15 threads in RWMutex.RLock and one in
RWMutex.Lock, all reached through netdev.Device.mu, which every Send and Recv
read-locks while Socket, Accept and Close write-lock it. A four-step
interleaving reproduces it deterministically; 16 readers and a writer around a
map hung 13 of 13 runs; the same netdev echo probe went from 11 of 40 hung to
0 of 40 with a plain mutex.

internal/syncx.RWMutex is sync.RWMutex on standard Go and a plain sync.Mutex
behind the same method set on tinygo and force_tinygo_logic. Every RWMutex a
tinygo build could reach now uses it: netdev's socket table and the darwin and
windows TLS session tables, httpmux, the dynamodb and datastore field caches,
the s3 region, fasthttp's HostClient map (as vendor.py patches, PATCHES.md
section 8) and the five registries in the mysql fork (PETITWEB comments,
README). Each critical section is a map lookup, so serializing readers costs
nothing measurable.

Three tests pin it down. TestRWMutexHandsOverToWaitingWriter runs the four-step
interleaving against the shim on every build. TestUpstreamRWMutexStillDeadlocks
(tinygo only) runs it against sync.RWMutex and expects the deadlock, so it
fails the day a TinyGo release ships the fix, which is the signal to retire the
shim. TestNoStdRWMutexOnTinyGoPath asks `go list -tags tinygo` for the files
of every package on darwin, linux and windows and parses them for the
selector, so neither first-party code nor a re-vendored fork can drift back.

Verified: go test and go test -tags force_tinygo_logic across the module;
tinygo test for syncx, netdev, httpmux, httprevproxy, fasthttp and
fasthttpwebsocket; `tinygo test ./websocket` 20 times under a watchdog with
0 hangs, against 5 of 16 before; the examples that touch the swapped packages
build, as does a linux/arm64 cross-link; mingw vets the windows session table.
TestLargeMessage's occasional EPIPE, seen on the untouched tree as well, is a
separate flake and is not changed by this.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
shibukawa added a commit to shibukawa/tinygodriver that referenced this pull request Sep 2, 2026
TinyGo's sync.RWMutex deadlocks whenever a reader arrives while a writer is
waiting; netdev.Device.mu is read on every Send and Recv and written on every
Socket, Accept and Close, which is how tinygo test ./websocket hung one run in
three. internal/syncx.RWMutex is the standard type on standard Go and a plain
mutex on TinyGo, every reachable RWMutex now uses it, a policy test keeps it
that way, and a tinygo-only test fails the day a release ships the upstream
fix (tinygo-org/tinygo#5630). 0 hangs in 20 runs, against 5 in 16.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
RWMutex counts the readers that hold the lock and the readers that queue
behind a waiting writer in one number, and both sides wait on a predicate over
that number. Two interleavings stop the program permanently.

A writer waits until the count shows no readers at all. A reader that arrives
during that wait joins the same count, so the last holder of the lock no
longer sees the condition that wakes the writer.

A reader that Unlock releases reads the count again instead of an acquire. A
writer that arrives in between changes the base of the count, so the reader
goes back to sleep after its wakeup is spent, while that writer waits for it.

Use the split that the standard library uses. A writer records how many
readers it finds and waits only for those, so later readers cannot starve it.
Counting semaphores hand the lock over, so a released waiter holds the lock
and does not test a value again that a third party can change back.
task.Semaphore cannot do this, because one Post does nothing when there are
several waiters, so the file gets a small futex semaphore that can.

Ordinary code reaches this. syscall.ForkLock is an RWMutex, os.Pipe read-locks
it and os.StartProcess write-locks it, so a program that starts processes and
makes pipes at the same time can stop.

The two new tests fail on the current code and pass with this change.
@yohimik
yohimik force-pushed the upstream-pr/sync-rwmutex branch from 7001593 to 6965ab2 Compare September 2, 2026 08:49
@yohimik

yohimik commented Sep 2, 2026

Copy link
Copy Markdown
Author

Rebased on dev after the 0.42.0 release. The problem is present in v0.42.0 as
released. The two new tests were put into the src/sync tree of the official
v0.42.0 tarballs and run with that toolchain. --- FAIL: TestRWMutexWriterNotStarvedByLateReaders (10.10s), "the writer did not wake
after the last reader unlocked", on darwin/arm64 and linux/arm64 in every run.
TestRWMutexHandoffToQueuedReaders is a race. It failed in 3 of 8 darwin runs.

yohimik added a commit to yohimik/tinygo that referenced this pull request Sep 5, 2026
Use posix_spawn on hosted Linux and Darwin. Map process files, apply the
working directory and process group, and clear the child signal mask.
Use wait4 for process status and support Kill and Signal.

Mark Darwin pipes close-on-exec under ForkLock. Darwin also needs the
fcntl wrapper in PR tinygo-org#5612 and the libSystem symbols in PR tinygo-org#5636.
Concurrent spawn and pipe creation need the RWMutex fix in PR tinygo-org#5630.

Keep the process stubs on other targets and add process regression tests.
@deadprogram

Copy link
Copy Markdown
Member

@yohimik this PR is just too much text for me to review. Please follow the latest AGENTS.md guidelines.

Also, what is the specific problem that you had caused you to work on this? Probably opening an issue first would be helpful.

Thanks!

yohimik added a commit to yohimik/tinygo that referenced this pull request Sep 17, 2026
Use posix_spawn on hosted Linux and Darwin. Map process files, apply the
working directory and process group, and clear the child signal mask.
Use wait4 for process status and support Kill and Signal.

Mark Darwin pipes close-on-exec under ForkLock. Darwin also needs the
fcntl wrapper in PR tinygo-org#5612 and the libSystem symbols in PR tinygo-org#5636.
Concurrent spawn and pipe creation need the RWMutex fix in PR tinygo-org#5630.

Keep the process stubs on other targets and add process regression tests.
@yohimik yohimik changed the title sync: hand the RWMutex over instead of a re-test of the reader count sync: prevent RWMutex deadlocks with queued readers Sep 17, 2026
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