WIP: fix(host): BroadcastConditionVariable and the timed semaphore acquires reach the HLE's objects #157
No reviewers
Labels
No labels
area/accessibility
area/audio
area/input
area/install
area/menu
area/renderer
area/saves
area/upscaling
good first issue
gpu/amd
gpu/igpu
gpu/intel
gpu/nvidia
help wanted
kind
bug
kind
chore
kind
docs
kind
feature
kind
performance
kind
question
needs measurements
platform/linux
platform/steam-deck
platform/windows
preview
priority
critical
priority
high
priority
low
status
blocked
status
confirmed
status
duplicate
status
invalid
status
needs-info
status
wontfix
testing wanted
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
MagicBeanDev/Longshot!157
Loading…
Reference in a new issue
No description provided.
Delete branch "klimovich008/Longshot:fix/cv-broadcast-hle"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Ported from Hookshot: https://github.com/NotRexed/Hookshot/pull/2765 (same commits, rebased onto Longshot main with the Longshot names).
Review there: Joey-Magnus at 563a184f, "The code looks right", asking for an in-game run with a movie before merging (the run is under Measurements): https://github.com/NotRexed/Hookshot/pull/2765#pullrequestreview-5876249386
Why
The movie framework's reply and buffer queues and NVIDIA's OS layer wait on condition variables that only BroadcastConditionVariable wakes, and the HLE never saw that Broadcast. Their waits are untimed, so a waiter got out only if some other Signal reached the same condition variable. This could explain movie hangs; it is not reproduced here, and no scene tested so far reaches these callers (Measurements):
this is hardening of those paths, not a fix of an observed hang.
What changed
The HLE replaces the nn::os condition variable and semaphore functions with host objects, but a few siblings were left to the game's own SDK code. That code acts on guest words the HLE never writes, and signals kernel keys that no HLE waiter sleeps on.
host/nn_os_sync.c):svcSignalProcessWideKey), which no HLE waiter sleeps on. The HLE'sWaitConditionVariablesleeps on its host object until the object's generation moves.cv->lkand wakes all waiters.cv_reserve): one mutex for all of them, at most 8 owed.HOS_NO_HANDOFF=1turns it off, as for Signal.TimedAcquireSemaphore,TimedAcquireLightSemaphore,TryAcquireLightSemaphore:sema_acquire_for, which waits up to the timeout. A timeout of 0 or less tries once.wait_until: sleeps in steps of at most 0x7FFFFFFF ms, because a finite wait of 0xFFFFFFFF ms isINFINITEon Windows. Its callers already wait again until their deadline.HOS_CHAIN, likesema_acquire's. Its deadline saturates for a huge TimeSpan.How these were found: the image's exports were compared with the HLE's list, per sync object type. Their callers were found
statically, in the image: the import slots holding each function's address, the stubs that load from them, and the calls to
those stubs. All are in the SDK, none in the game's own code: for Broadcast, the movie framework's message reply and its live
data source's buffer and end-of-stream queues, and NVIDIA's OS layer; for the timed acquire, nlib's semaphore wait, the C
library's timed semaphore wait and NVIDIA's OS layer. Siblings still left to the SDK that have direct callers:
StopTimerEvent(the C library's timers) andRegisterSystemWorkerHandler. The HLE timer has no separate "signalled" state, so a correct Stop needs a model change; it is left for a follow-up.Flags and settings
None new.
HOS_NO_HANDOFF=1(existing) now also turns off the Broadcast's hand-off.Measurements
A
fix: the proof of the code is the game-free test, which fails on main and passes here. In the game, the question is whetherplay reaches the changed code at all, so each run below counted every call: a local log line for each Broadcast and each timed
or try acquire, added to the measured build only (not part of this PR).
main:
839e85ff(2026-11-13)this PR: fix/cv-broadcast-hle @
d98acf80(measured as this PR's three commits on839e85ff, as a dev_packs pack, plus the local log line)Hardware: RX 9070 XT, AMD 26.10.2, Ryzen 7 5700X3D, 64 GB, Windows 11
Scene: each scene from its save, 16:9, frame rate unlocked, the fixed Custom profile; one run each, about 55 s from the start
(the save loads at about 29 s, then 20+ s of play)
Method: counting runs, not an A/B: the log lines counted per run
Baseline: this PR @
d98acf80+ log line: Broadcast calls (a count needs no other arm: main has no Broadcast HLE, so main makesno such call through the HLE by construction). The counts hold on main
514591a3: the merge changes neither of this PR's files.d98acf80+ log line: Broadcast callsd98acf80+ log line: timed or try acquires46780503, the same change, earlier)No scene tested reaches the changed code. So nothing here can show the movie-hang link, and nothing here can show a cost: with
no calls, the change does no work in these scenes.
Testing
Tested: this branch (first on Longshot main
39647e1aand1be180bb; merged with main514591a3since,e76dd95e, with the checks run again there), Windows 11, Ryzen 7 5700X3D:tests/test_os_lifted_siblings.py(new) runs the real code cut from the source (cv_enter,cv_leave,cv_reserve, the Broadcast HLE,sema_acquire_for) over counting stubs and a fake clock.HOS_NO_HANDOFFunset,1and0.HOS_CHAINnotes.sema_acquire_for, andwait_until's clamp belowINFINITE.tests/test_multiwait_wake.pypasses.python -I -B tests/run_all.pyon this branch merged with main514591a3(LONGSHOT_MINGW= llvm-mingw 20261104): 496 passed, 4 failed, 34 skipped.The failures fail the same way on unmodified main: nx2/test_nup_paths.py, tests/test_check_third_party.py, tests/test_menu_i18n.py (fixed by #273), tests/test_release.py.
host/*.c(llvm-mingw clang, against a build's generated runtime headers): the changed files compile. 5 files stop at library headers this PC lacks (dav1d, zstd, opus, libavcodec), the same on main.Not tested: a scene that reaches Broadcast in the game (none of the four above does), so whether this fixes a movie hang
is not shown; Linux (test request T-ZD24); macOS.
Needs other testers:
lines (T-ZD24)
Screenshots
Nothing on screen.
Risk and rollback
Every Broadcast caller (the movie framework's queues, NVIDIA's OS layer) slept until some unrelated Signal; now they wake on time. That changes timing on movie and NVN OS-layer paths. A wrong wake or hand-off would show as a stall or a hang on those paths.
HOS_NO_HANDOFF=1turns the hand-off off; reverting the commits restores the SDK's own Broadcast and acquires.Reviewer notes
Merge order with #156 (three per-call costs, including Signal's early return): both change
host/nn_os_sync.cnext to Signal and merge without a textual conflict. Whichever lands second needs a short follow-up. That PR counts a waiter in (cv_waiting) before it takes the condition variable's lock, and keeps the waiters under the lock incv_in:cv_in - cv_owedinstead ofcv_waiting - cv_owed;cv_in;That fix goes in whichever PR merges second, in the same PR, not as a follow-up (HumanBean): the Broadcast counting from
cv_in, the fixture'scv_in, and the new case. The Signal change may now land through TrashPanda's #288 instead of #156;the rule is the same.
Left for follow-ups, named here (HumanBean's review):
StopTimerEventandRegisterSystemWorkerHandler(above).tombstone reuse clears
cv_owed,cv_waitingandgen, but the mutex keeps thereservedcount those reservationsadded, so ordinary lockers are refused for good. Pre-existing; Broadcast can leak up to 8 reservations where Signal leaked
one. Fix: give back
cv_owed_mutex[0..cv_owed)when a K_COND is dropped or made fresh.TimedWaitConditionVariable,event_waitand the timed queue receive still computenow + timeoutwithout saturating, soa TimeSpan near INT64_MAX wraps to a deadline in the past. Fix: one shared
deadline_after(ns)withsema_acquire_for'ssaturation.
Issues
None.
Checklist
For everyone
type(scope): description).main.python -I -B tests/run_all.pyrun and passing (any failure that also fails onmainis named above).third_party/untouched (a new folder with aPROVENANCE.mdinstead).If applicable
Assisted-by:/Co-Authored-By:trailers.mainand passing here.🤖 Generated with Claude Code
The code does what it says, and I read it against nn::os:
genundercv->lkand wakes everyone after the lock is let go, so no wakeup is lost (a waiter recordsgenunder the same lock before it gives the mutex back). Each waiter not yet owed the mutex is owed it (cv_waiting - cv_owed, capped bycv_reserve's 8 and switched off bycv_mixedandHOS_NO_HANDOFF). That matches Horizon'sSignalProcessWideKey(-1), which moves every waiter onto the mutex. Waiters past the 8th fall back tomutex_lock, andmutex_give's wake-all whilereserved > 0keeps them from stalling.TimeSpanis int64 nanoseconds passed in x1, asTimedWaitEventalready reads it.<= 0tries once. The deadline saturates. The loop copes with spurious wakeups and the vcore away/back re-lock. The bool return is the count it took.wait_until's clamp to 0x7FFFFFFF ms fixes a real truncation, where a long finite wait could becomeINFINITEor 0 ms. On Linux,rel_ms()and the futex take 0x7FFFFFFF ms without overflow.nx2/standalone.pyhooks anHLE()symbol at the sdk's own definition as well as at the imports, so SDK-internal callers reach it too. The four mangled names are spelled correctly.hos_system_event_*, not a condition variable or a timed semaphore. Your own run shows the prologue movie makes no Broadcast call, so its numbers should not move. Nothing else underhost/references these symbols.Must change
"Rebased on current
main" is ticked but not true. The PR sits on1be180bb, andmainhas about 29 commits since then (#78, #147, #197 and others).host/nn_os_sync.chas not changed on main since the fork, and the test file is new, so the rebase should be clean. Rebase and run the checks again, or untick the box.The in-game run never reaches the change. In the prologue movie Broadcast was called 0 times, so the run Joey-Magnus asked for in https://github.com/NotRexed/Hookshot/pull/2765 shows no regression on a path that this PR leaves untouched. The Why says the movie framework's reply and buffer queues wait on condition variables that only Broadcast wakes, but a movie that played through called Broadcast 0 times. Please do three things:
TimedAcquireSemaphore(keep the local log line).Until then it stays WIP, as the Measured box already says.
Should change
scripts/test_requests.py), and change the reason beside the box.cv_leave".obj_lookupreuses the tombstone and memsetscv_owed,cv_waitingandgen. Thereservedcount those reservations added to the mutex stays, somutex_takerefuses every ordinary locker of that mutex for good. Thegenreset to 0 can also put back to sleep a waiter that savedgen == 0. With Signal this leaked one reservation, and with Broadcast it leaks up to 8. A small fix would give backcv_owed_mutex[0..cv_owed)(reserved--) when a K_COND is dropped or made fresh. A follow-up is fine, but name it in the PR.wait_untilcomment says callers wait again until the deadline. ButTimedWaitConditionVariable(hos_now_ns() + ARG(2)),event_waitand the timed queue receive still computenow + timeoutwithout saturating. ATimeSpannear INT64_MAX, used as a "forever" timeout, overflows to a negative deadline and times out at once. A shareddeadline_after(ns)with the saturation fromsema_acquire_forwould fix all of them. This is optional for this PR.@ -283,3 +283,3 @@int64_t left = deadline - hos_now_ns();if (left <= 0) return 0;hos_cv_sleep(&o->cv, &o->lk, (DWORD)((left + 999999) / 1000000));/* (in steps of at most ~24 days: 0xFFFFFFFF ms is INFINITE; callers wait again until the deadline) */Good catch: before this, a long finite wait could truncate to
INFINITEor to 0 ms. On Linux,rel_ms()and the futex take 0x7FFFFFFF ms fine. The comment's "callers wait again until the deadline" holds for everywait_untilcaller as long as their deadline did not wrap (see the comment onsema_acquire_for).@ -589,0 +596,4 @@if (watched_cv(ARG(0))) watch_log("broadcast", watch_mutex(), ARG(0), c, NULL);HOS_CHAIN(HOS_CHAIN_SIGNAL, cv->key);AcquireSRWLockExclusive(&cv->lk);for (int n = cv->cv_waiting - cv->cv_owed; n > 0; n--) cv_reserve(cv);Correct against Horizon's Broadcast, which moves every waiter onto the mutex. One hazard is now up to 8 times larger, though it is pre-existing. Suppose the condition variable is finalized and initialized again at the same address before these woken waiters reach
cv_leave.obj_lookup's tombstone reuse then memsetscv_owed,cv_waitingandgen, but the mutex'sreservedkeeps these reservations, somutex_takerefuses every ordinary locker of that mutex for good. Giving backcv_owed_mutex[0..cv_owed)when a K_COND is dropped or made fresh would close it. A follow-up is fine.@ -919,0 +934,4 @@/* an acquire that waits at most ns (<= 0: does not wait); whether it got one */static int sema_acquire_for(Obj *s, int64_t ns) {const int64_t now = hos_now_ns();const int64_t deadline = ns <= 0 ? now : ns > INT64_MAX - now ? INT64_MAX : now + ns; /* (a huge TimeSpan: no wrap) */This saturation is right. The other timed paths still compute
now + timeoutwithout it:TimedWaitConditionVariable,event_waitand the timed queue receive. A TimeSpan near INT64_MAX wraps to a negative deadline there and times out at once, so the clamp inwait_untilnever gets to help them. A shareddeadline_after(ns)helper would cover them all.2e1e2e8cinto fix/cv-broadcast-hle a4eac68674514591a3into fix/cv-broadcast-hleThanks for the review, HumanBean. Each point:
Must change
514591a3is merged in (e76dd95e, no force-push; the PR's diff is still its two files).run_allon the merge: 496 passed, 4 failed, 34 skipped; the 4 (test_nup_paths,test_check_third_party,test_menu_i18n,test_release) fail the same way on unmodified514591a3(test_menu_i18nis #138's renamed row, fixed by #273).test_os_lifted_siblingsandtest_multiwait_wakepass. The box is true again.839e85ff) counted 0 Broadcast calls and 0 timed or try acquires in Hyrule Field, Kokiri Forest and the Temple of Time route, as the prologue movie had 0 Broadcasts on Hookshot.Should change
now + timeoutpaths: both named as follow-ups in Reviewer notes, with the fixes you suggested (giving backcv_owed_mutex[0..cv_owed)on a K_COND's drop or fresh init; one shareddeadline_after(ns)).Description only: the Measurements' Baseline line names its column, and says why the counts hold on main
514591a3(the merge changes neither of this PR's files).View command line instructions
Checkout
From your project repository, check out a new branch and test the changes.