Virtual cores: a thread that runs without a core (escape) shares the held core's per-core state, crashing after the intro #1929

Open
opened 2026-11-10 20:52:45 +00:00 by CIB · 19 comments
CIB commented 2026-11-10 20:52:45 +00:00 (Migrated from github.com)

Hookshot version

v2.0.7 on Linux, built from source with extra logging for this report (hookshot v2.0.7-dirty, source 83662a3b).
I also checked the newest release's source, tag v2.0.14: vc_acquire in host/hos_thread.c still runs a waiter
without a core after HOS_VCORE_ESCAPE_US/_EQ_US (both default to 250000 µs), as does main (8c92fe3). I did
not run v2.0.14 itself; the instrumented reproduction build is v2.0.7-dirty.

What kind of problem

Crash, freeze or black screen.

What happened

A few seconds after pressing Start at the intro, the game crashes on about 9 of 10 runs that reach the title. The
crash site moves between runs (resource loading, compose and worker functions), and the value that is read there is a
valid pointer into a different core's table, not NULL or random, which is why pointer checks did not catch it.

The cause is the virtual-core scheduler's escape: a thread that has waited 250 ms for a core is allowed to run
without one. The game's thread entry caches the core number it was given in a TLS slot at thread start and uses that
cached number for per-core state (allocators, job and resource queues) without locks. An escaped thread keeps its
cached number, so it and the thread that owns that core use the same per-core state at the same time.

Evidence, with a fresh user directory and Start scripted at the same movie frame every run:

  • No escapes from the third AV1 decoder ready (test switch HOS_VCORE_STRICT): 5 of 5 title-reaching runs
    survive, ~55-60 fps, 0 escapes after the switch was armed. Several fresh-user attempts failed before the title
    and are excluded as boot failures, not cue crashes.
  • Escapes re-enabled 1 ms after that point: the crash returns (7 escapes after the switch, 40 fps).
  • Escapes re-enabled 250 ms after that point: the crash returns (3 escapes after the switch, 51 fps).

The escape log and the cached-core check name the mechanism (instrumented build; thread names and core numbers only).
A filtered scheduler log and the run matrix are in the evidence comment;
the full log is retained locally because it contains asset names and local paths.

hookshot_log.txt (excerpts)

tls-core: sead::MainThread cached core+1=1 (live core 0)
vcores: PreActorModelCompose ... ran without a core after 250.1 ms: waiter_tls=1
        core 0: sead::MainThread (16) tls=1; ...
vcores: PreActorModelCompose ... waiter_tls=6
        core 5: BaseProcExecutor5 (16) tls=6; ...
vcores: ARes Loading 3 ... waiter_tls=5
        core 4: BaseProcExecutor4 (16) tls=5; ...
CORETLS thread=PreActorModelCompose cached_core_plus1=5 live_core=4
CRASH: exception 0xc0000005 ...

waiter_tls (the core+1 the escaped thread cached) equals the holder's tls: both index the same per-core state at
the same time, and the crash follows. The same happens for every thread that escapes while another thread holds its
core; the introduction is just where this game first does enough per-core work to hit it reliably.

Steps to reproduce

  1. Start with a fresh --user directory so the menu is New Game (no existing save).
  2. Start the game and let the intro/title sequence play. The crash point is the third intro movie; the process dies a
    few seconds after Start is accepted there.
  3. For the reliable timing, press Start after about 90-100 seconds of title playback (after the first
    AV1 decoder ready). The automated runs use HOS_START_AT_ACQUIRE=5500, which fires Start on the 5500th
    decoder-output acquire, about 90 seconds into the run.
  4. Press Start (Plus, A, A with the scripted input) to enter the title/game.
  5. With the default scheduler, the game crashes a few seconds later on about 9 of 10 runs that reach the title.

Timing note: pressing Start immediately when the title movie begins can sometimes get past this cue. It is not a
reliable workaround: in runs where it got past the cue, it sometimes crashed later (for example in ARes Loading).
The reliable reproduction is the delayed Start above. The HOS_VCORE_STRICT runs use the same scripted Start and
only change the escape policy at the third AV1 decoder ready.

Where in the game

The third intro movie, pressing Start to enter the title/game. With a new game each run it is reproducible.

How often

About 9 of 10 runs that reach the title. 5 of 5 survived with escapes disabled from the third movie.

Graphics card and driver version

NVIDIA GeForce RTX 4090, NVIDIA 590.48.01 (Vulkan 1.4).

CPU, RAM and operating system

AMD Ryzen 7 5700X3D, 128 GB, Arch Linux (the Linux build; the scheduler code is shared with Windows).

Changes from the defaults

HOS_CORES=6 (default). The runs above used test switches only to arm/disarm the escape and to log the cached core;
no game files or caches were changed. The full test build is local and is not attached.

Core count

HOS_CORES is the number of virtual cores, not the host's physical core count. The default is 6 (the game's
six-core model), so a machine with 8 or 16 physical cores still runs 6 unless HOS_CORES is raised. This host has
8 physical cores/16 threads; the 6 is the virtual-core setting, not a hardware limit.

More virtual cores can reduce how often every allowed core is held, which may lower the escape/crash rate. It is not
a cure: #1420 logged 32 escapes in about 10 minutes with HOS_CORES=8, and the scheduler caps at 8. The fix must make
the escape impossible, not rely on a larger core count.

Other software hooked into the game

No, nothing hooked in.

The fix I am working on

I am implementing INV-CORE in host/hos_thread.c:

  1. every guest thread gets one home core the first time it is scheduled and keeps it for its life;
  2. GetCurrentCoreNumber() and the value cached in TLS always agree (no migration after that value is written);
  3. at most one thread runs guest code on a core at a time;
  4. the core is released on every block, and the holder is preempted (including a time slice after
    HOS_VCORE_PREEMPT_US) instead of ever running the waiter without a core.

The patch is in progress and will come with the full validation matrix (boot, second boot, 8 title-reaching runs at
~60 fps, save/load, cached core == home core, another supported game). No game code, assets, generated C or saves are
included; the log lines above are thread names and core numbers only.

No duplicate found. #1420 reports the same 250 ms waits as a low-FPS symptom and was closed into #995; #1696 fixes one
WaitAny/mw_lock escape path and its "Not covered" section leaves other escapes separate. #1543 is diagnostics, not
this bug. #1697/#1786 are the RTX 30-series post-intro crash, and #1742/#1875 are the above-60-fps cutscene-timing
family. This report is the scheduler correctness/crash consequence and proposes a stable per-thread core.

Before you send it

  • I'm on the newest release, or the problem is still there on it. (Checked v2.0.14's source: same escape path and 250000 µs defaults. I did not run v2.0.14 itself; the reproduction build is v2.0.7-dirty.)
  • I searched the open issues for this problem.
  • I'm not attaching game files, keys, hookshot.exe, the .bin caches or saves.
### Hookshot version v2.0.7 on Linux, built from source with extra logging for this report (`hookshot v2.0.7-dirty`, source `83662a3b`). I also checked the newest release's source, tag `v2.0.14`: `vc_acquire` in `host/hos_thread.c` still runs a waiter without a core after `HOS_VCORE_ESCAPE_US`/`_EQ_US` (both default to 250000 µs), as does `main` (`8c92fe3`). I did not run v2.0.14 itself; the instrumented reproduction build is v2.0.7-dirty. ### What kind of problem Crash, freeze or black screen. ### What happened A few seconds after pressing Start at the intro, the game crashes on about 9 of 10 runs that reach the title. The crash site moves between runs (resource loading, compose and worker functions), and the value that is read there is a **valid pointer into a different core's table**, not NULL or random, which is why pointer checks did not catch it. The cause is the virtual-core scheduler's **escape**: a thread that has waited 250 ms for a core is allowed to run *without* one. The game's thread entry caches the core number it was given in a TLS slot at thread start and uses that cached number for per-core state (allocators, job and resource queues) **without locks**. An escaped thread keeps its cached number, so it and the thread that owns that core use the same per-core state at the same time. Evidence, with a fresh user directory and Start scripted at the same movie frame every run: - **No escapes from the third `AV1 decoder ready`** (test switch `HOS_VCORE_STRICT`): **5 of 5** title-reaching runs survive, ~55-60 fps, **0 escapes after the switch was armed**. Several fresh-user attempts failed before the title and are excluded as boot failures, not cue crashes. - **Escapes re-enabled 1 ms after that point**: the crash returns (7 escapes after the switch, 40 fps). - **Escapes re-enabled 250 ms after that point**: the crash returns (3 escapes after the switch, 51 fps). The escape log and the cached-core check name the mechanism (instrumented build; thread names and core numbers only). A filtered scheduler log and the run matrix are in the [evidence comment](https://git.magicbean.dev/MagicBeanDev/Hookshot/issues/1929); the full log is retained locally because it contains asset names and local paths. ### hookshot_log.txt (excerpts) ``` tls-core: sead::MainThread cached core+1=1 (live core 0) vcores: PreActorModelCompose ... ran without a core after 250.1 ms: waiter_tls=1 core 0: sead::MainThread (16) tls=1; ... vcores: PreActorModelCompose ... waiter_tls=6 core 5: BaseProcExecutor5 (16) tls=6; ... vcores: ARes Loading 3 ... waiter_tls=5 core 4: BaseProcExecutor4 (16) tls=5; ... CORETLS thread=PreActorModelCompose cached_core_plus1=5 live_core=4 CRASH: exception 0xc0000005 ... ``` `waiter_tls` (the core+1 the escaped thread cached) equals the holder's `tls`: both index the same per-core state at the same time, and the crash follows. The same happens for every thread that escapes while another thread holds its core; the introduction is just where this game first does enough per-core work to hit it reliably. ### Steps to reproduce 1. Start with a fresh `--user` directory so the menu is New Game (no existing save). 2. Start the game and let the intro/title sequence play. The crash point is the third intro movie; the process dies a few seconds after Start is accepted there. 3. For the reliable timing, press Start after about 90-100 seconds of title playback (after the first `AV1 decoder ready`). The automated runs use `HOS_START_AT_ACQUIRE=5500`, which fires Start on the 5500th decoder-output acquire, about 90 seconds into the run. 4. Press Start (Plus, A, A with the scripted input) to enter the title/game. 5. With the default scheduler, the game crashes a few seconds later on about 9 of 10 runs that reach the title. **Timing note:** pressing Start immediately when the title movie begins can sometimes get past this cue. It is not a reliable workaround: in runs where it got past the cue, it sometimes crashed later (for example in `ARes Loading`). The reliable reproduction is the delayed Start above. The `HOS_VCORE_STRICT` runs use the same scripted Start and only change the escape policy at the third `AV1 decoder ready`. ### Where in the game The third intro movie, pressing Start to enter the title/game. With a new game each run it is reproducible. ### How often About 9 of 10 runs that reach the title. 5 of 5 survived with escapes disabled from the third movie. ### Graphics card and driver version NVIDIA GeForce RTX 4090, NVIDIA 590.48.01 (Vulkan 1.4). ### CPU, RAM and operating system AMD Ryzen 7 5700X3D, 128 GB, Arch Linux (the Linux build; the scheduler code is shared with Windows). ### Changes from the defaults `HOS_CORES=6` (default). The runs above used test switches only to arm/disarm the escape and to log the cached core; no game files or caches were changed. The full test build is local and is not attached. ### Core count `HOS_CORES` is the number of virtual cores, not the host's physical core count. The default is 6 (the game's six-core model), so a machine with 8 or 16 physical cores still runs 6 unless `HOS_CORES` is raised. This host has 8 physical cores/16 threads; the 6 is the virtual-core setting, not a hardware limit. More virtual cores can reduce how often every allowed core is held, which may lower the escape/crash rate. It is not a cure: #1420 logged 32 escapes in about 10 minutes with `HOS_CORES=8`, and the scheduler caps at 8. The fix must make the escape impossible, not rely on a larger core count. ### Other software hooked into the game No, nothing hooked in. ### The fix I am working on I am implementing **INV-CORE** in `host/hos_thread.c`: 1. every guest thread gets one **home core** the first time it is scheduled and keeps it for its life; 2. `GetCurrentCoreNumber()` and the value cached in TLS always agree (no migration after that value is written); 3. at most one thread runs guest code on a core at a time; 4. the core is released on every block, and the holder is **preempted** (including a time slice after `HOS_VCORE_PREEMPT_US`) instead of ever running the waiter without a core. The patch is in progress and will come with the full validation matrix (boot, second boot, 8 title-reaching runs at ~60 fps, save/load, cached core == home core, another supported game). No game code, assets, generated C or saves are included; the log lines above are thread names and core numbers only. ### Related issues (not duplicates) No duplicate found. #1420 reports the same 250 ms waits as a low-FPS symptom and was closed into #995; #1696 fixes one `WaitAny`/`mw_lock` escape path and its "Not covered" section leaves other escapes separate. #1543 is diagnostics, not this bug. #1697/#1786 are the RTX 30-series post-intro crash, and #1742/#1875 are the above-60-fps cutscene-timing family. This report is the scheduler correctness/crash consequence and proposes a stable per-thread core. ### Before you send it - [x] I'm on the newest release, or the problem is still there on it. (Checked `v2.0.14`'s source: same escape path and 250000 µs defaults. I did not run `v2.0.14` itself; the reproduction build is `v2.0.7-dirty`.) - [x] I searched the open issues for this problem. - [x] I'm not attaching game files, keys, `hookshot.exe`, the `.bin` caches or saves.
CIB commented 2026-11-10 21:09:15 +00:00 (Migrated from github.com)

Sanitized scheduler evidence

The GitHub API cannot upload text attachments, so the filtered files are inline below.

Only scheduler/core lines are kept. Local paths, save IDs, asset and scene names, guest code addresses and full logs are not included. No game code, generated C, caches or saves are included.

Run matrix
# Validation matrix for issue #1929

Definition: **after arm** means after the third `AV1 decoder ready` line, where `HOS_VCORE_STRICT` was armed in these runs. Crash count is `CRASH: exception` / `FATAL:` after that line. Escapes are `ran without a core` lines after that line. FPS is the last `... fps` value in the log.

## Title-reaching runs

| Arm | Run | Reached title | Crashes after arm | Escapes after arm | Last fps | Result |
|---|---|---|---:|---:|---:|---|
| Control: no escape through the cue | `sr232` | yes | 0 | 0 | 60.0 | survived |
| Control: no escape through the cue | `sr233` | yes | 0 | 0 | 59.8 | survived |
| Control: no escape through the cue | `sv1` | yes | 0 | 0 | 54.8 | survived |
| Control: no escape through the cue | `ctl73-2` | yes | 0 | 0 | 60.0 | survived |
| Control: no escape through the cue | `ctl2-1` | yes | 0 | 0 | 60.0 | survived |
| Escapes re-enabled 1 ms after arm | `m1-2-1` | yes | 1 | 7 | 40.1 | crashed (`BaseProcCreateAndDeleteThread`) |
| Escapes re-enabled 250 ms after arm | `m250-70-2` | yes | 2 | 3 | 50.8 | crashed (`Resource Memory`, `Resource Control`) |

Control: **5/5 title-reaching runs survived, 0 escapes after the arm**.
Intervention: re-enabling escapes even 1 ms after the arm brings the crash back, with escapes immediately before it.

## Runs excluded from the denominator

Several fresh-user runs failed before the title (no `AV1 decoder ready`). Those are boot failures, not cue crashes, and are excluded from the 5/5 and from the 1 ms/250 ms crash counts. Fresh-user boot is independently flaky in this build.

## Measurement notes

- `HOS_VCORE_STRICT` is local instrumentation, not an upstream switch. It prevents the `vc_acquire()` escape at the cue.
- `HOS_VCORE_STRICT_MS=1` and `=250` re-enable the normal escape after that many milliseconds, without otherwise changing the run.
- The control is the same build and the same scripted Start frame, with the no-escape arm left in place.
- The `tls-core`, `waiter_tls` and `CORETLS` lines in the attached logs are also local instrumentation.

## Core count

All runs in this matrix used the default `HOS_CORES=6`. An 8-core A/B is not included; one 8-core attempt
boot-failed before the title and is not counted. #1420's logs already show escapes with `HOS_CORES=8`, so a larger
core count is not a cure.
TLS-core evidence (filtered log)
# Sanitized TLS-core excerpts for issue #1929
# Build: hookshot v2.0.7-dirty (source 83662a3b), Linux, RTX 4090, NVIDIA 590.48.01.
# Actual first log line:
[hos host] hookshot v2.0.7-dirty (source 83662a3b)
# Only scheduler/core lines are kept. Paths, save IDs, asset names and guest code addresses are removed.
#
# waiter_tls is the core+1 cached by the waiter. The holder's tls= is the same core+1.
# This is the subset of escapes where the two values match; other escapes in the same
# log do not have a matching holder and are not used as evidence here.

tls-core: sead::MainThread cached core+1=1 (live core 0)
tls-core: ARes Loading 3 cached core+1=5 (live core 4)
tls-core: BaseProcExecutor4 cached core+1=5 (live core 4)
tls-core: BaseProcExecutor5 cached core+1=6 (live core 5)

vcores: PreActorModelCompose (priority 26) ran without a core after 250.1 ms: waiter_tls=1 core 0: sead::MainThread (16) tls=1;
vcores: MovieEventHandleThread (priority 17) ran without a core after 250.1 ms: waiter_tls=5 core 4: ModuleSystemWorker4 (16) asked tls=5;
vcores: GeneralFormatAsync_ (priority 24) ran without a core after 250.1 ms: waiter_tls=5 core 4: ModuleSystemWorker4 (16) tls=5;
vcores: PreActorModelCompose (priority 26) ran without a core after 250.3 ms: waiter_tls=2 core 1: AudioFrameworkThread (4) tls=2;
vcores: tera::FileResourceMgr (priority 25) ran without a core after 251.0 ms: waiter_tls=2 core 1: AudioFrameworkThread (4) tls=2;
vcores: ARes Loading 1 (priority 19) ran without a core after 250.1 ms: waiter_tls=5 core 4: BaseProcExecutor4 (16) tls=5;
vcores: GeneralFormatAsync_ (priority 24) ran without a core after 250.7 ms: waiter_tls=6 core 5: BaseProcExecutor5 (16) tls=6;
vcores: BaseProcCreateAndDeleteThread (priority 23) ran without a core after 250.2 ms: waiter_tls=6 core 5: BaseProcExecutor5 (16) tls=6;
vcores: BaseProcCreateAndDeleteThread (priority 23) ran without a core after 250.6 ms: waiter_tls=5 core 4: BaseProcExecutor4 (16) tls=5;
vcores: PreActorModelCompose (priority 26) ran without a core after 251.0 ms: waiter_tls=6 core 5: BaseProcExecutor5 (16) tls=6;
vcores: ARes Loading 3 (priority 18) ran without a core after 250.1 ms: waiter_tls=5 core 4: BaseProcExecutor4 (16) tls=5;

CORETLS thread=PreActorModelCompose cached_core_plus1=5 live_core=4
CRASH: exception 0xc0000005

# Notes:
# - The three lines quoted in the issue are the PreActorModelCompose/sead::MainThread,
#   PreActorModelCompose/BaseProcExecutor5 and ARes Loading 3/BaseProcExecutor4 cases above.
# - CORETLS shows the crashing thread's cached core and the core it currently holds. In this
#   run they agree (cached core 4, live core 4); the failure is the earlier concurrent use of
#   the same per-core state by an escaped waiter and the holder.
# - The guest address in the crash line and the guest/host code frames are intentionally omitted.
Intervention evidence (filtered log)
# Sanitized intervention excerpts for issue #1929
# Build: hookshot v2.0.7-dirty (source 83662a3b), Linux, RTX 4090, NVIDIA 590.48.01.
# Only scheduler/core and crash lines are kept. Paths, save IDs, asset names,
# Japanese thread names, guest code addresses and register dumps are removed.

## Control: no escape through the cue (ctl73-2)
# 0 "ran without a core" lines after the third AV1 decoder ready
# 0 CRASH/FATAL lines
# last fps: 60.0
# (There are no post-arm escape lines to quote; the control is the absence of escapes.)

## Escapes re-enabled 1 ms after the arm (m1-2-1)
vcores: PreActorModelCompose (priority 26) ran without a core after 250.2 ms: core 5: BaseProcExecutor5 (16);
vcores: MovieEventHandleThread (priority 17) ran without a core after 250.0 ms: core 4: BaseProcExecutor4 (16);
vcores: VideoDecoderThread (priority 16) ran without a core after 250.1 ms: core 0: sead::MainThread (16); core 1: ModuleSystemWorker1 (16) asked; core 2: MediaExtractorWorker (16); core 3: ModuleSystemWorker3 (16);
vcores: [asset decode thread] (priority 19) ran without a core after 250.1 ms: core 4: SceneLowPriority (17) asked;
vcores: MovieMediaSourceClient (priority 17) ran without a core after 250.9 ms: core 1: ModuleSystemWorker1 (16) asked; core 2: MediaExtractorWorker (16); core 3: ModuleSystemWorker3 (16); core 4: SceneLowPriority (17) asked;
CRASH: exception 0xc0000005
# escapes after the arm: 7
# crashes after the arm: 1
# last fps: 40.1

## Escapes re-enabled 250 ms after the arm (m250-70-2)
vcores: [asset decode thread] (priority 19) ran without a core after 250.0 ms: core 4: BaseProcExecutor4 (16);
vcores: MovieEventHandleThread (priority 17) ran without a core after 250.9 ms: core 4: BaseProcExecutor4 (16);
vcores: GeneralFormatAsync_ (priority 24) ran without a core after 250.2 ms: core 4: ModuleSystemWorker4 (16);
CRASH: exception 0xc0000005
CRASH: exception 0xc0000005
# escapes after the arm: 3
# crashes after the arm: 2
# last fps: 50.8

# Notes:
# - "After the arm" means after the third AV1 decoder ready line, where HOS_VCORE_STRICT was armed.
# - The 1 ms arm is the strongest causal control: it changes only the escape deadline, and the
#   crash returns with escapes immediately before it.
# - The 250 ms arm also brings the crash back, with fewer escapes in the short post-arm window.
# - Several other fresh-user attempts failed before the title and are excluded as boot failures.
# - One repeated thread name is shortened to "[asset decode thread]"; it is an asset-decoding
#   thread, not a reference to any other project.
Host-only stall backtrace excerpt
# Sanitized host-only excerpt from a gdb all-thread backtrace at a cue stall
# Guest frames and binary paths are removed. The remaining frames show the wait path:
# the named threads are blocked in the host condition-variable/queue waits.

Thread "sead::MainThread":
  SleepConditionVariableSRW
  hos_cv_sleep
  event_wait

Thread "Presentation Thread":
  SleepConditionVariableSRW
  hos_cv_sleep
  queue_get

Thread "ARes Loading 0":
  SleepConditionVariableSRW
  hos_cv_sleep
  queue_get

Thread "ARes Loading 1":
  SleepConditionVariableSRW
  hos_cv_sleep
  queue_get

# Interpretation: the virtual-core holders in this stall are not in long compute; they are
# blocked in the host waits above. A waiter that cannot get a core then escapes after the
# deadline and runs beside the holder, which is the mechanism in the issue.
#
# This backtrace is supporting evidence from a separate stall run. It is not one of the
# 1 ms/250 ms intervention runs.
Run recipe
# How the runs were driven

All runs used the same Linux test build and a fresh `--user` directory (new game), with the cache files used in place and not copied.

1. Start the game with a fresh `--user` directory, `HOS_VIDEO_LOG=1` and a scripted-input file (`HOS_INPUT_LIVE`).
2. Press Start at the same decoder-acquire point in every run (`HOS_START_AT_ACQUIRE=5500`, about 90 s into the run; the scripted input is `0:PLUS,1:A,2:A`).
3. Wait for the third `AV1 decoder ready` line and arm the no-escape test switch there (`HOS_VCORE_STRICT`).
4. Run for 45 s after Start, or until the process exits.
5. Count `ran without a core`, `CRASH: exception` and `FATAL:` lines only after the arm.

Timing note: pressing Start immediately when the title movie begins can sometimes get past the cue. It is not a
reliable workaround; in some of those runs the process crashed later. The delayed Start above is the reliable
reproduction.

## Arms

| Arm | Change |
|---|---|
| Control | `HOS_VCORE_STRICT` stays armed; no escape deadline |
| 1 ms | `HOS_VCORE_STRICT_MS=1` re-enables the normal escape 1 ms after the arm |
| 250 ms | `HOS_VCORE_STRICT_MS=250` re-enables the normal escape 250 ms after the arm |

## Diagnostics

- `HOS_VCORE_TLS_CHECK=1`: log the TLS core cache and flag mismatches.
- `HOS_VCORE_LOG=1`: log each thread's home core.
- `HOS_VCORE_DUMP_MS=3000`: dump the holders and queued threads on a long wait.
- The `tls-core`, `waiter_tls` and `CORETLS` lines are local instrumentation, not upstream features.

The instrumented build is local and is not attached. No game files, generated C, caches or saves are needed to read the evidence.

The control runs have zero escapes after the arm; the 1 ms and 250 ms arms bring the crash back. The matching waiter_tls/holder tls= lines are the direct evidence that an escaped waiter and the core holder can index the same per-core state.

### Sanitized scheduler evidence The GitHub API cannot upload text attachments, so the filtered files are inline below. Only scheduler/core lines are kept. Local paths, save IDs, asset and scene names, guest code addresses and full logs are not included. No game code, generated C, caches or saves are included. <details> <summary>Run matrix</summary> ~~~text # Validation matrix for issue #1929 Definition: **after arm** means after the third `AV1 decoder ready` line, where `HOS_VCORE_STRICT` was armed in these runs. Crash count is `CRASH: exception` / `FATAL:` after that line. Escapes are `ran without a core` lines after that line. FPS is the last `... fps` value in the log. ## Title-reaching runs | Arm | Run | Reached title | Crashes after arm | Escapes after arm | Last fps | Result | |---|---|---|---:|---:|---:|---| | Control: no escape through the cue | `sr232` | yes | 0 | 0 | 60.0 | survived | | Control: no escape through the cue | `sr233` | yes | 0 | 0 | 59.8 | survived | | Control: no escape through the cue | `sv1` | yes | 0 | 0 | 54.8 | survived | | Control: no escape through the cue | `ctl73-2` | yes | 0 | 0 | 60.0 | survived | | Control: no escape through the cue | `ctl2-1` | yes | 0 | 0 | 60.0 | survived | | Escapes re-enabled 1 ms after arm | `m1-2-1` | yes | 1 | 7 | 40.1 | crashed (`BaseProcCreateAndDeleteThread`) | | Escapes re-enabled 250 ms after arm | `m250-70-2` | yes | 2 | 3 | 50.8 | crashed (`Resource Memory`, `Resource Control`) | Control: **5/5 title-reaching runs survived, 0 escapes after the arm**. Intervention: re-enabling escapes even 1 ms after the arm brings the crash back, with escapes immediately before it. ## Runs excluded from the denominator Several fresh-user runs failed before the title (no `AV1 decoder ready`). Those are boot failures, not cue crashes, and are excluded from the 5/5 and from the 1 ms/250 ms crash counts. Fresh-user boot is independently flaky in this build. ## Measurement notes - `HOS_VCORE_STRICT` is local instrumentation, not an upstream switch. It prevents the `vc_acquire()` escape at the cue. - `HOS_VCORE_STRICT_MS=1` and `=250` re-enable the normal escape after that many milliseconds, without otherwise changing the run. - The control is the same build and the same scripted Start frame, with the no-escape arm left in place. - The `tls-core`, `waiter_tls` and `CORETLS` lines in the attached logs are also local instrumentation. ## Core count All runs in this matrix used the default `HOS_CORES=6`. An 8-core A/B is not included; one 8-core attempt boot-failed before the title and is not counted. #1420's logs already show escapes with `HOS_CORES=8`, so a larger core count is not a cure. ~~~ </details> <details> <summary>TLS-core evidence (filtered log)</summary> ~~~text # Sanitized TLS-core excerpts for issue #1929 # Build: hookshot v2.0.7-dirty (source 83662a3b), Linux, RTX 4090, NVIDIA 590.48.01. # Actual first log line: [hos host] hookshot v2.0.7-dirty (source 83662a3b) # Only scheduler/core lines are kept. Paths, save IDs, asset names and guest code addresses are removed. # # waiter_tls is the core+1 cached by the waiter. The holder's tls= is the same core+1. # This is the subset of escapes where the two values match; other escapes in the same # log do not have a matching holder and are not used as evidence here. tls-core: sead::MainThread cached core+1=1 (live core 0) tls-core: ARes Loading 3 cached core+1=5 (live core 4) tls-core: BaseProcExecutor4 cached core+1=5 (live core 4) tls-core: BaseProcExecutor5 cached core+1=6 (live core 5) vcores: PreActorModelCompose (priority 26) ran without a core after 250.1 ms: waiter_tls=1 core 0: sead::MainThread (16) tls=1; vcores: MovieEventHandleThread (priority 17) ran without a core after 250.1 ms: waiter_tls=5 core 4: ModuleSystemWorker4 (16) asked tls=5; vcores: GeneralFormatAsync_ (priority 24) ran without a core after 250.1 ms: waiter_tls=5 core 4: ModuleSystemWorker4 (16) tls=5; vcores: PreActorModelCompose (priority 26) ran without a core after 250.3 ms: waiter_tls=2 core 1: AudioFrameworkThread (4) tls=2; vcores: tera::FileResourceMgr (priority 25) ran without a core after 251.0 ms: waiter_tls=2 core 1: AudioFrameworkThread (4) tls=2; vcores: ARes Loading 1 (priority 19) ran without a core after 250.1 ms: waiter_tls=5 core 4: BaseProcExecutor4 (16) tls=5; vcores: GeneralFormatAsync_ (priority 24) ran without a core after 250.7 ms: waiter_tls=6 core 5: BaseProcExecutor5 (16) tls=6; vcores: BaseProcCreateAndDeleteThread (priority 23) ran without a core after 250.2 ms: waiter_tls=6 core 5: BaseProcExecutor5 (16) tls=6; vcores: BaseProcCreateAndDeleteThread (priority 23) ran without a core after 250.6 ms: waiter_tls=5 core 4: BaseProcExecutor4 (16) tls=5; vcores: PreActorModelCompose (priority 26) ran without a core after 251.0 ms: waiter_tls=6 core 5: BaseProcExecutor5 (16) tls=6; vcores: ARes Loading 3 (priority 18) ran without a core after 250.1 ms: waiter_tls=5 core 4: BaseProcExecutor4 (16) tls=5; CORETLS thread=PreActorModelCompose cached_core_plus1=5 live_core=4 CRASH: exception 0xc0000005 # Notes: # - The three lines quoted in the issue are the PreActorModelCompose/sead::MainThread, # PreActorModelCompose/BaseProcExecutor5 and ARes Loading 3/BaseProcExecutor4 cases above. # - CORETLS shows the crashing thread's cached core and the core it currently holds. In this # run they agree (cached core 4, live core 4); the failure is the earlier concurrent use of # the same per-core state by an escaped waiter and the holder. # - The guest address in the crash line and the guest/host code frames are intentionally omitted. ~~~ </details> <details> <summary>Intervention evidence (filtered log)</summary> ~~~text # Sanitized intervention excerpts for issue #1929 # Build: hookshot v2.0.7-dirty (source 83662a3b), Linux, RTX 4090, NVIDIA 590.48.01. # Only scheduler/core and crash lines are kept. Paths, save IDs, asset names, # Japanese thread names, guest code addresses and register dumps are removed. ## Control: no escape through the cue (ctl73-2) # 0 "ran without a core" lines after the third AV1 decoder ready # 0 CRASH/FATAL lines # last fps: 60.0 # (There are no post-arm escape lines to quote; the control is the absence of escapes.) ## Escapes re-enabled 1 ms after the arm (m1-2-1) vcores: PreActorModelCompose (priority 26) ran without a core after 250.2 ms: core 5: BaseProcExecutor5 (16); vcores: MovieEventHandleThread (priority 17) ran without a core after 250.0 ms: core 4: BaseProcExecutor4 (16); vcores: VideoDecoderThread (priority 16) ran without a core after 250.1 ms: core 0: sead::MainThread (16); core 1: ModuleSystemWorker1 (16) asked; core 2: MediaExtractorWorker (16); core 3: ModuleSystemWorker3 (16); vcores: [asset decode thread] (priority 19) ran without a core after 250.1 ms: core 4: SceneLowPriority (17) asked; vcores: MovieMediaSourceClient (priority 17) ran without a core after 250.9 ms: core 1: ModuleSystemWorker1 (16) asked; core 2: MediaExtractorWorker (16); core 3: ModuleSystemWorker3 (16); core 4: SceneLowPriority (17) asked; CRASH: exception 0xc0000005 # escapes after the arm: 7 # crashes after the arm: 1 # last fps: 40.1 ## Escapes re-enabled 250 ms after the arm (m250-70-2) vcores: [asset decode thread] (priority 19) ran without a core after 250.0 ms: core 4: BaseProcExecutor4 (16); vcores: MovieEventHandleThread (priority 17) ran without a core after 250.9 ms: core 4: BaseProcExecutor4 (16); vcores: GeneralFormatAsync_ (priority 24) ran without a core after 250.2 ms: core 4: ModuleSystemWorker4 (16); CRASH: exception 0xc0000005 CRASH: exception 0xc0000005 # escapes after the arm: 3 # crashes after the arm: 2 # last fps: 50.8 # Notes: # - "After the arm" means after the third AV1 decoder ready line, where HOS_VCORE_STRICT was armed. # - The 1 ms arm is the strongest causal control: it changes only the escape deadline, and the # crash returns with escapes immediately before it. # - The 250 ms arm also brings the crash back, with fewer escapes in the short post-arm window. # - Several other fresh-user attempts failed before the title and are excluded as boot failures. # - One repeated thread name is shortened to "[asset decode thread]"; it is an asset-decoding # thread, not a reference to any other project. ~~~ </details> <details> <summary>Host-only stall backtrace excerpt</summary> ~~~text # Sanitized host-only excerpt from a gdb all-thread backtrace at a cue stall # Guest frames and binary paths are removed. The remaining frames show the wait path: # the named threads are blocked in the host condition-variable/queue waits. Thread "sead::MainThread": SleepConditionVariableSRW hos_cv_sleep event_wait Thread "Presentation Thread": SleepConditionVariableSRW hos_cv_sleep queue_get Thread "ARes Loading 0": SleepConditionVariableSRW hos_cv_sleep queue_get Thread "ARes Loading 1": SleepConditionVariableSRW hos_cv_sleep queue_get # Interpretation: the virtual-core holders in this stall are not in long compute; they are # blocked in the host waits above. A waiter that cannot get a core then escapes after the # deadline and runs beside the holder, which is the mechanism in the issue. # # This backtrace is supporting evidence from a separate stall run. It is not one of the # 1 ms/250 ms intervention runs. ~~~ </details> <details> <summary>Run recipe</summary> ~~~text # How the runs were driven All runs used the same Linux test build and a fresh `--user` directory (new game), with the cache files used in place and not copied. 1. Start the game with a fresh `--user` directory, `HOS_VIDEO_LOG=1` and a scripted-input file (`HOS_INPUT_LIVE`). 2. Press Start at the same decoder-acquire point in every run (`HOS_START_AT_ACQUIRE=5500`, about 90 s into the run; the scripted input is `0:PLUS,1:A,2:A`). 3. Wait for the third `AV1 decoder ready` line and arm the no-escape test switch there (`HOS_VCORE_STRICT`). 4. Run for 45 s after Start, or until the process exits. 5. Count `ran without a core`, `CRASH: exception` and `FATAL:` lines only after the arm. Timing note: pressing Start immediately when the title movie begins can sometimes get past the cue. It is not a reliable workaround; in some of those runs the process crashed later. The delayed Start above is the reliable reproduction. ## Arms | Arm | Change | |---|---| | Control | `HOS_VCORE_STRICT` stays armed; no escape deadline | | 1 ms | `HOS_VCORE_STRICT_MS=1` re-enables the normal escape 1 ms after the arm | | 250 ms | `HOS_VCORE_STRICT_MS=250` re-enables the normal escape 250 ms after the arm | ## Diagnostics - `HOS_VCORE_TLS_CHECK=1`: log the TLS core cache and flag mismatches. - `HOS_VCORE_LOG=1`: log each thread's home core. - `HOS_VCORE_DUMP_MS=3000`: dump the holders and queued threads on a long wait. - The `tls-core`, `waiter_tls` and `CORETLS` lines are local instrumentation, not upstream features. The instrumented build is local and is not attached. No game files, generated C, caches or saves are needed to read the evidence. ~~~ </details> The control runs have zero escapes after the arm; the 1 ms and 250 ms arms bring the crash back. The matching `waiter_tls`/holder `tls=` lines are the direct evidence that an escaped waiter and the core holder can index the same per-core state.
NotRexed commented 2026-11-10 23:08:30 +00:00 (Migrated from github.com)

Thanks for the careful analysis. I reproduced the escapes on Windows (v2.0.14 line and main, RTX 4060 Ti): about 20–30 per run. I tried the obvious fix, making the escape a last resort, with an A/B of HOS_VCORE_ESCAPE_US/_EQ_US=5000000 against the default:

run escapes longest core wait worst frame crashes
default 1 22 251.6 ms 584 ms 0
5 s 1 17 5001.7 ms 5022 ms 0
default 2 33 251.8 ms 456 ms 0

With 5 s the waits don't end early; threads wait the whole 5 s and escape anyway. In those escapes every holder of the waiter's cores had been asked to give its core up ("asked") and didn't for 5 s. For example, the Presentation Thread (17) waited while all six cores were held by priority-16 threads (sead::MainThread, nn.npln.*, ModuleSystemWorker3, …), all asked.

Since an asked holder gives its core up at its next checkpoint, these holders must be stuck in host code that waits without releasing its virtual core, probably on something the waiting thread would do. So the escape is currently a deadlock breaker, and a longer timeout turns the race you found into multi-second freezes. I won't ship that.

The next step is to log, at each escape, where each holder is waiting (its last host call), find the host waits that keep their core, and release the core around them. Then escapes shouldn't be needed in normal play, and the race goes away with them.

🤖 Generated with Claude Code

Thanks for the careful analysis. I reproduced the escapes on Windows (v2.0.14 line and main, RTX 4060 Ti): about 20–30 per run. I tried the obvious fix, making the escape a last resort, with an A/B of `HOS_VCORE_ESCAPE_US`/`_EQ_US`=5000000 against the default: | run | escapes | longest core wait | worst frame | crashes | |---|---|---|---|---| | default 1 | 22 | 251.6 ms | 584 ms | 0 | | 5 s 1 | **17** | **5001.7 ms** | **5022 ms** | 0 | | default 2 | 33 | 251.8 ms | 456 ms | 0 | With 5 s the waits don't end early; threads wait the whole 5 s and escape anyway. In those escapes every holder of the waiter's cores had been asked to give its core up ("asked") and didn't for 5 s. For example, the Presentation Thread (17) waited while all six cores were held by priority-16 threads (sead::MainThread, nn.npln.*, ModuleSystemWorker3, …), all asked. Since an asked holder gives its core up at its next checkpoint, these holders must be stuck in host code that waits without releasing its virtual core, probably on something the waiting thread would do. So the escape is currently a deadlock breaker, and a longer timeout turns the race you found into multi-second freezes. I won't ship that. The next step is to log, at each escape, where each holder is waiting (its last host call), find the host waits that keep their core, and release the core around them. Then escapes shouldn't be needed in normal play, and the race goes away with them. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
NotRexed commented 2026-11-11 04:40:27 +00:00 (Migrated from github.com)

Update: #2127 (merged) raises the vcore escape from 250 ms to 2 s.

On current main (after 9f42700e), the waits that reached 250 ms end by themselves within about 1.2 s. A/B on the 25-minute route, two rounds each:

escapes longest wait p99 max frame
250 ms 8 / 6 251 / 252 ms 28.9 / 27.5 ms 641 / 584 ms
2 s 0 / 0 909 / 1162 ms 28.6 / 27.4 ms 619 / 573 ms

There were no escapes at 2 s, so no thread ran without its core, and frame times were the same or better. The escape stays as a deadlock breaker.

Update: #2127 (merged) raises the vcore escape from 250 ms to 2 s. On current main (after 9f42700e), the waits that reached 250 ms end by themselves within about 1.2 s. A/B on the 25-minute route, two rounds each: | | escapes | longest wait | p99 | max frame | |---|---|---|---|---| | 250 ms | 8 / 6 | 251 / 252 ms | 28.9 / 27.5 ms | 641 / 584 ms | | 2 s | 0 / 0 | 909 / 1162 ms | 28.6 / 27.4 ms | 619 / 573 ms | There were no escapes at 2 s, so no thread ran without its core, and frame times were the same or better. The escape stays as a deadlock breaker.
mooki000 commented 2026-11-11 14:08:13 +00:00 (Migrated from github.com)

Linux results for the next step you named (where each holder waits at an escape), from the reporter's kind of machine:
Arch, RTX 4090, NVIDIA 615.78.08, headless, a fresh profile, Start pressed about 95 s into the title as in the issue's
recipe.

Method. A local diagnostic build (main's scheduler plus a log, not for merging) writes, when a thread has waited
100 ms for a core and again at each escape, every holder of the cores it may use: how long it has held its core, whether
it is running or asleep, and its host stack, sampled with the profiler's signal sampler and named with llvm-symbolizer.
Two runs of it, 16 escapes.

Two host waits that keep a core (one stack each, outermost first; game code shown as (game)):

  1. OpenFile while it builds the button-prompt textures. When the prompt textures have to be written again (a first
    start, an update that changes them, another controller face), the first open builds them inside host_path, and
    OpenFile gives its core up only later, around CreateFileA:
    (game) > OpenFile > host_path > hos_mods_romfs > hos_prompts_romfs > build_textures > write_patched > fwrite
    (fread in other samples). ARes Loading 0 had held core 5 for 357 ms by the last sample, running (in the reads and
    writes of the 244 MB file) and asked to give the core up; four threads escaped within 42 ms (ModuleSystemWorker5,
    GeneralFormatAsync_, ARes Loading 2, PreActorModelCompose).
  2. The movie decoder in AcquireXDecoderOutputBuffer. dec_acquire decodes with the core held, and dav1d waits there
    for its own frame threads:
    (game) > dec_acquire > decode_next > avcodec_receive_frame_flags > (libavcodec) > dav1d_get_picture > dav1d_parse_obus > dav1d_submit_frame > pthread_cond_wait.
    The movie input thread (ムービー入力読込, priority 17) held core 5 there for 270 ms, asleep and asked; BaseProcExecutor5
    escaped after 250.4 ms, and AudioFrameworkThread a moment later from the same holder. This is the other half of what I
    posted on #2007 (the movie's own threads escaping at a movie's start): here the decoder holding its core makes other
    threads escape.

The other escapes' holders were in game code with no host frame above it: sead::MainThread at the start (262-309 ms),
and, in the scene load after Start, low-priority threads (bake::ModuleMgr and BakeProbe 28, PreActorModelCompose 26,
tera::FileResourceMgr and aglTextureDataMgr::Compaction 25) whose one core went from one better thread to the next
(each held it 0.1-2.2 ms).

An escape outlasts its wait. After vc_acquire lets a thread run without a core, hos_vcore_away() answers 0 for it
at every later block (it holds no core to give up), so the if (away) hos_vcore_back(); after the block never takes one
again: the thread runs coreless, with its old core number, until it exits. The diagnostic build counts those blocks: in
the two runs, 11 escaped threads logged their 1000th block without a core between 0.2 s and 100 s after their escape,
and ModuleSystemWorker5 its 100000th, 68 s after.

Counts (the escape at 250 ms where named, so that waits over 250 ms show as escapes; one sitting, interleaved, not a
quiet machine; "behind" = the escape's holder was the prompt build's thread right then, or the movie input thread when
asked):

runs escapes per run behind the prompt build behind the decoder crashes
Issue steps (165 s; the last four of main's: 115 s)
main's code at the old 250 ms 7 7, 8, 2, 2, 8, 3, 5 0 in all 4, 0, 0, 0, 0, 0, 0 0
main now (2 s) 3 0, 0, 0 0 0 0
prompts change, 250 ms 3 6, 2, 3 0 0 0
decoder change, 250 ms 6 7, 4, 3, 8, 5, 6 0 0 in all 0
escape ends at the next block, 250 ms 3 16, 20, 27 0 3, 0, 7 0
First start (prompt textures written again), 30 s
main's code at the old 250 ms 3 1, 6, 1 0, 4, 0 0, 1, 0 0
prompts change, 250 ms 3 1, 1, 1 0, 0, 0 0 0
main now (2 s) 3 0, 0, 0 0 0 0
prompts change, 2 s 3 0, 0, 0 0 0 0

What they show: the prompt build makes threads escape only at a first start, and not at every one (1 of 3 here, and the
first diagnostic run, which was also a first start); with the change, never. Escapes behind the decoder came in 5 of 16
runs on builds without the decoder change (the table's 13 at 165/115 s, the two diagnostic runs and one earlier run; 17
escapes), in none of the 6 with it. At 2 s nothing escaped (as on your route). Mean frame rates were the same within the
spread in every arm (not a quiet machine; the two runs started at load 66 and while a build pushed it to 218 were slower).
Letting an escape end at the next block raises the count
at 250 ms, as expected: on main an escaped thread never waits again; with that change the same low-priority threads
wait again after their block and escape again (by name up to 11 times in a run).

Pull requests, each from current main with a game-free test that compiles the actual function and fails on main:

  • #2301: OpenFile's existing away/back pair starts before host_path, so the prompt build (and threads waiting
    for it) hold no core.
  • #2302: an escape lasts until the thread's next block, then it waits for a core like every other thread.

A third change, the decoder's (dec_acquire gives its core up for the whole call and takes it again only after
d->lock is let go, as hos_cv_sleep does), is being measured on a quiet machine because it also seems to cut slow
frames during the movies; it follows with those numbers. Not tested: Windows (Media Foundation decodes there instead of
dav1d), AMD, the Steam Deck, anything past the first minute after Start.

Corrected 2026-11-11 16:19 UTC: I had written "up to four times each in a run". Several threads share one name, so these logs give no count per thread; by name the most was PreActorModelCompose, 11 times in one run.

Linux results for the next step you named (where each holder waits at an escape), from the reporter's kind of machine: Arch, RTX 4090, NVIDIA 615.78.08, headless, a fresh profile, Start pressed about 95 s into the title as in the issue's recipe. **Method.** A local diagnostic build (main's scheduler plus a log, not for merging) writes, when a thread has waited 100 ms for a core and again at each escape, every holder of the cores it may use: how long it has held its core, whether it is running or asleep, and its host stack, sampled with the profiler's signal sampler and named with llvm-symbolizer. Two runs of it, 16 escapes. **Two host waits that keep a core** (one stack each, outermost first; game code shown as `(game)`): 1. **`OpenFile` while it builds the button-prompt textures.** When the prompt textures have to be written again (a first start, an update that changes them, another controller face), the first open builds them inside `host_path`, and `OpenFile` gives its core up only later, around `CreateFileA`: `(game) > OpenFile > host_path > hos_mods_romfs > hos_prompts_romfs > build_textures > write_patched > fwrite` (`fread` in other samples). ARes Loading 0 had held core 5 for 357 ms by the last sample, running (in the reads and writes of the 244 MB file) and asked to give the core up; four threads escaped within 42 ms (ModuleSystemWorker5, GeneralFormatAsync_, ARes Loading 2, PreActorModelCompose). 2. **The movie decoder in `AcquireXDecoderOutputBuffer`.** `dec_acquire` decodes with the core held, and dav1d waits there for its own frame threads: `(game) > dec_acquire > decode_next > avcodec_receive_frame_flags > (libavcodec) > dav1d_get_picture > dav1d_parse_obus > dav1d_submit_frame > pthread_cond_wait`. The movie input thread (ムービー入力読込, priority 17) held core 5 there for 270 ms, asleep and asked; BaseProcExecutor5 escaped after 250.4 ms, and AudioFrameworkThread a moment later from the same holder. This is the other half of what I posted on #2007 (the movie's own threads escaping at a movie's start): here the decoder holding its core makes other threads escape. The other escapes' holders were in game code with no host frame above it: sead::MainThread at the start (262-309 ms), and, in the scene load after Start, low-priority threads (bake::ModuleMgr and BakeProbe 28, PreActorModelCompose 26, tera::FileResourceMgr and aglTextureDataMgr::Compaction 25) whose one core went from one better thread to the next (each held it 0.1-2.2 ms). **An escape outlasts its wait.** After `vc_acquire` lets a thread run without a core, `hos_vcore_away()` answers 0 for it at every later block (it holds no core to give up), so the `if (away) hos_vcore_back();` after the block never takes one again: the thread runs coreless, with its old core number, until it exits. The diagnostic build counts those blocks: in the two runs, 11 escaped threads logged their 1000th block without a core between 0.2 s and 100 s after their escape, and ModuleSystemWorker5 its 100000th, 68 s after. **Counts** (the escape at 250 ms where named, so that waits over 250 ms show as escapes; one sitting, interleaved, not a quiet machine; "behind" = the escape's holder was the prompt build's thread right then, or the movie input thread when asked): | | runs | escapes per run | behind the prompt build | behind the decoder | crashes | |---|---:|---|---|---|---:| | **Issue steps (165 s; the last four of main's: 115 s)** | | | | | | | main's code at the old 250 ms | 7 | 7, 8, 2, 2, 8, 3, 5 | 0 in all | 4, 0, 0, 0, 0, 0, 0 | 0 | | main now (2 s) | 3 | 0, 0, 0 | 0 | 0 | 0 | | prompts change, 250 ms | 3 | 6, 2, 3 | 0 | 0 | 0 | | decoder change, 250 ms | 6 | 7, 4, 3, 8, 5, 6 | 0 | 0 in all | 0 | | escape ends at the next block, 250 ms | 3 | 16, 20, 27 | 0 | 3, 0, 7 | 0 | | **First start (prompt textures written again), 30 s** | | | | | | | main's code at the old 250 ms | 3 | 1, 6, 1 | 0, 4, 0 | 0, 1, 0 | 0 | | prompts change, 250 ms | 3 | 1, 1, 1 | 0, 0, 0 | 0 | 0 | | main now (2 s) | 3 | 0, 0, 0 | 0 | 0 | 0 | | prompts change, 2 s | 3 | 0, 0, 0 | 0 | 0 | 0 | What they show: the prompt build makes threads escape only at a first start, and not at every one (1 of 3 here, and the first diagnostic run, which was also a first start); with the change, never. Escapes behind the decoder came in 5 of 16 runs on builds without the decoder change (the table's 13 at 165/115 s, the two diagnostic runs and one earlier run; 17 escapes), in none of the 6 with it. At 2 s nothing escaped (as on your route). Mean frame rates were the same within the spread in every arm (not a quiet machine; the two runs started at load 66 and while a build pushed it to 218 were slower). Letting an escape end at the next block raises the count at 250 ms, as expected: on main an escaped thread never waits again; with that change the same low-priority threads wait again after their block and escape again (by name up to 11 times in a run). Pull requests, each from current main with a game-free test that compiles the actual function and fails on main: - #2301: `OpenFile`'s existing away/back pair starts before `host_path`, so the prompt build (and threads waiting for it) hold no core. - #2302: an escape lasts until the thread's next block, then it waits for a core like every other thread. A third change, the decoder's (`dec_acquire` gives its core up for the whole call and takes it again only after `d->lock` is let go, as `hos_cv_sleep` does), is being measured on a quiet machine because it also seems to cut slow frames during the movies; it follows with those numbers. Not tested: Windows (Media Foundation decodes there instead of dav1d), AMD, the Steam Deck, anything past the first minute after Start. *Corrected 2026-11-11 16:19 UTC: I had written "up to four times each in a run". Several threads share one name, so these logs give no count per thread; by name the most was `PreActorModelCompose`, 11 times in one run.*
mooki000 commented 2026-11-11 15:00:54 +00:00 (Migrated from github.com)

The third change is up as #2322: no core held while a movie picture is decoded (AcquireXDecoderOutputBuffer).

The numbers I promised, from six runs in turn on a quiet machine (the title and its movie, RTX 4090, Linux, defaults): the 1% low is 33.0 to 34.6 fps on main and 39.7 to 42.5 with the change, frames over 25 ms are 28 to 40 against 6 to 12, and the mean stays at the 60 fps cap. With the escape set to 250 ms, builds without the change had 17 escapes behind the decoder in 5 of 16 runs, and with it none in 6. The per-run table is in the pull request. Windows (Media Foundation) is not tested.

The third change is up as #2322: no core held while a movie picture is decoded (`AcquireXDecoderOutputBuffer`). The numbers I promised, from six runs in turn on a quiet machine (the title and its movie, RTX 4090, Linux, defaults): the 1% low is 33.0 to 34.6 fps on main and 39.7 to 42.5 with the change, frames over 25 ms are 28 to 40 against 6 to 12, and the mean stays at the 60 fps cap. With the escape set to 250 ms, builds without the change had 17 escapes behind the decoder in 5 of 16 runs, and with it none in 6. The per-run table is in the pull request. Windows (Media Foundation) is not tested.
CIB commented 2026-11-11 15:24:42 +00:00 (Migrated from github.com)

Update: classify the 250 ms escape by whether the holder is making progress

I instrumented the escape path to capture, at the 250 ms event, the holder's host stack and whether it
reaches a checkpoint. That gives a concrete split:

  • Progressing guest code (the harmful case). One of the common holders is the worker's decompression
    loop. It polls at every loop back-edge (the polls are present and compiled), and it is better
    priority
    than the waiter (19 vs 26). vc_kick is only set for a worse or equal holder, so the kick is
    never set; every poll is a no-op. The worse waiter waits the full 250 ms and escapes, then runs on the
    decompressor's core while it is still decompressing. That concurrency is the per-core corruption.
  • Non-polling host waits (the deadlock-breaker case). The other holders (the Opus decoder, 14, and the
    nn.npln worker/shared path, 16) are inside host code that makes no checkpoint. Those are exactly the
    holders your 5 s experiment found; for them the escape is still needed until the host wait releases the
    core.

Evidence

Default 250 ms, 100 s-Start run, Linux main, guest window on the non-dma-buf path:

vcores: PreActorModelCompose (26) ran without a core after 250.1 ms:
        core 5: ParallelDecompThread (19) wait=-;
vcores: GeneralFormatAsync_ (24) ran without a core after 250.0 ms:
        core 5: ParallelDecompThread (19) wait=-;

At the stop point, the holder's host stack was entirely lifted guest code:

thread entry -> shared worker loop -> decompression job entry -> decompressor

No hos_cv_sleep, no host mutex, no FS/GPU/NVN call — so this is not a "host wait that keeps the core"
in the usual sense. The decompressor's lifted body contains 26 polls, one at every backward branch
(18 compiled in the binary), and the poll-hit trace showed the holder reaching them hundreds of times.
The loop polls; the scheduler simply never asks it to yield, because vc_kick is not set for a holder
that is better priority than the waiter.

With the progress-aware rule below, the same run no longer has the ParallelDecompThread escape; the
only escapes left are non-polling host holders:

vcores: BakeProbe (28) ran without a core after 272.4 ms:
        core 1: OpusDecoder (14) wait=-;
vcores: PlacementMgr (26) ran without a core after 358.4 ms:
        core 2: nn.npln.Shared (16) wait=-;

Proposed change (small, scheduler only, no lift change)

Record a per-thread last_checkpoint time. At the 250 ms deadline, escape only if no holder on the
waiter's allowed cores has checkpointed since the waiter started
. A polling holder is making progress in
guest code — Horizon would simply run it — so the waiter keeps waiting. A holder stuck in host code (no
checkpoints) still triggers the escape.

/* in the checkpoint: */
t->last_checkpoint = now();

/* in vc_acquire, at the escape deadline: */
int progressed = 0;
for (each allowed core c)
    if (vc_owner[c] && vc_owner[c]->last_checkpoint >= waiter_start)
        progressed = 1;
if (!progressed && waited > escape_timeout)
    escape();   /* the deadlock breaker, only for a holder that cannot progress */

Reproduction (host harness; no game files)

fresh --user; HOS_VIDEO_LOG=1; HOS_VCORE_LOG=1; HOS_POLL_TRACE=1;
HOS_START_AT_ACQUIRE=0;
run; wait for "AV1 decoder ready"; sleep 100; append "0:PLUS,1:A,2:A" to HOS_INPUT_LIVE;
run 45 s and count "ran without a core".

Relevant local numbers: the default run shows the decompressor escape (the log cap is 40 lines, so
several per run); progress-aware 0 decompressor escapes; escapes disabled entirely (1 h threshold) 2/2 runs clean, boot and cue. (The
Linux cue crash we chased separately was a guest-window issue (memfd vs anonymous window), not this
scheduler issue; those runs had the Vulkan backend not started, so they were not a dma-buf result, and
it is tracked apart from this one.)


🤖 Generated with opencode using DeepSeek V4.1 Flash.

### Update: classify the 250 ms escape by whether the holder is making progress I instrumented the escape path to capture, at the 250 ms event, the holder's **host stack** and whether it reaches a checkpoint. That gives a concrete split: - **Progressing guest code (the harmful case).** One of the common holders is the worker's decompression loop. It polls at every loop back-edge (the polls are present and compiled), and it is **better priority** than the waiter (19 vs 26). `vc_kick` is only set for a worse or equal holder, so the kick is never set; every poll is a no-op. The worse waiter waits the full 250 ms and escapes, then runs on the decompressor's core while it is still decompressing. That concurrency is the per-core corruption. - **Non-polling host waits (the deadlock-breaker case).** The other holders (the Opus decoder, 14, and the `nn.npln` worker/shared path, 16) are inside host code that makes no checkpoint. Those are exactly the holders your 5 s experiment found; for them the escape is still needed until the host wait releases the core. ### Evidence Default 250 ms, 100 s-Start run, Linux `main`, guest window on the non-dma-buf path: ``` vcores: PreActorModelCompose (26) ran without a core after 250.1 ms: core 5: ParallelDecompThread (19) wait=-; vcores: GeneralFormatAsync_ (24) ran without a core after 250.0 ms: core 5: ParallelDecompThread (19) wait=-; ``` At the stop point, the holder's **host stack** was entirely lifted guest code: ``` thread entry -> shared worker loop -> decompression job entry -> decompressor ``` No `hos_cv_sleep`, no host mutex, no FS/GPU/NVN call — so this is not a "host wait that keeps the core" in the usual sense. The decompressor's lifted body contains **26 polls, one at every backward branch** (18 compiled in the binary), and the poll-hit trace showed the holder reaching them hundreds of times. The loop polls; the scheduler simply never asks it to yield, because `vc_kick` is not set for a holder that is better priority than the waiter. With the progress-aware rule below, the same run no longer has the `ParallelDecompThread` escape; the only escapes left are non-polling host holders: ``` vcores: BakeProbe (28) ran without a core after 272.4 ms: core 1: OpusDecoder (14) wait=-; vcores: PlacementMgr (26) ran without a core after 358.4 ms: core 2: nn.npln.Shared (16) wait=-; ``` ### Proposed change (small, scheduler only, no lift change) Record a per-thread `last_checkpoint` time. At the 250 ms deadline, escape **only if no holder on the waiter's allowed cores has checkpointed since the waiter started**. A polling holder is making progress in guest code — Horizon would simply run it — so the waiter keeps waiting. A holder stuck in host code (no checkpoints) still triggers the escape. ```c /* in the checkpoint: */ t->last_checkpoint = now(); /* in vc_acquire, at the escape deadline: */ int progressed = 0; for (each allowed core c) if (vc_owner[c] && vc_owner[c]->last_checkpoint >= waiter_start) progressed = 1; if (!progressed && waited > escape_timeout) escape(); /* the deadlock breaker, only for a holder that cannot progress */ ``` ### Reproduction (host harness; no game files) ``` fresh --user; HOS_VIDEO_LOG=1; HOS_VCORE_LOG=1; HOS_POLL_TRACE=1; HOS_START_AT_ACQUIRE=0; run; wait for "AV1 decoder ready"; sleep 100; append "0:PLUS,1:A,2:A" to HOS_INPUT_LIVE; run 45 s and count "ran without a core". ``` Relevant local numbers: the default run shows the decompressor escape (the log cap is 40 lines, so several per run); progress-aware **0** decompressor escapes; escapes disabled entirely (1 h threshold) **2/2** runs clean, boot and cue. (The Linux cue crash we chased separately was a guest-window issue (memfd vs anonymous window), not this scheduler issue; those runs had the Vulkan backend not started, so they were not a dma-buf result, and it is tracked apart from this one.) --- 🤖 Generated with [opencode](https://opencode.ai) using DeepSeek V4.1 Flash.
CIB commented 2026-11-11 15:26:21 +00:00 (Migrated from github.com)

Sanitized log excerpts for the update above

GitHub's API cannot attach files to a comment, so these are inline sanitized excerpts. All paths, game addresses, lifted function names and labels are redacted; only thread names, priorities, core numbers and counters remain.

Default 250 ms (Linux main, non-dma-buf guest window)

escapes: 9; crashes: 0
[hos PreActorModelCompose] vcores: PreActorModelCompose (priority 26) ran without a core after 250.1 ms: core 0: sead::MainThread (16) wait=-;
[hos PreActorModelCompose] vcores: PreActorModelCompose (priority 26) ran without a core after 250.1 ms: core 5: ParallelDecompThread (19) wait=-;
[hos GeneralFormatAsync_] vcores: GeneralFormatAsync_ (priority 24) ran without a core after 250.0 ms: core 5: ParallelDecompThread (19) wait=-;
[hos aglTextureDataMgr::Compaction] vcores: aglTextureDataMgr::Compaction (priority 25) ran without a core after 250.1 ms: core 1: ModuleSystemWorker1 (16) wait=-;
poll-hit lines (cap 400): 400, e.g.
[hos ParallelDecompThread] poll-hit: ParallelDecompThread kick 2 core 0
[hos ParallelDecompThread] poll-hit: ParallelDecompThread kick 2 core 4

Progress-aware escape (same arm)

escapes: 2; crashes: 0
[hos BakeProbe] vcores: BakeProbe (priority 28) ran without a core after 272.4 ms: core 1: OpusDecoder (14) wait=-;
[hos bake::ModuleMgr] vcores: bake::ModuleMgr (priority 28) ran without a core after 273.3 ms: core 1: OpusDecoder (14) wait=-;

Escapes disabled entirely (1 h threshold), 100 s arm

noesc1: crashes=0 escapes=0 fps=60.0 fps
noesc2: crashes=0 escapes=0 fps=60.0 fps

Host stack at the stop (sanitized)

thread entry
  shared worker loop
    decompression job entry
      decompressor        <-- holder, >250 ms
(no hos_cv_sleep, no host mutex, no FS/GPU/NVN call)

Reproduction (host harness; no game files)

fresh --user; HOS_VIDEO_LOG=1 HOS_VCORE_LOG=1 HOS_POLL_TRACE=1 HOS_START_AT_ACQUIRE=0
run; wait for "AV1 decoder ready"; sleep 100
append "0:PLUS,1:A,2:A" to HOS_INPUT_LIVE; run 45 s
count "ran without a core" in the log

🤖 Generated with opencode using DeepSeek V4.1 Flash.

### Sanitized log excerpts for the update above GitHub's API cannot attach files to a comment, so these are inline sanitized excerpts. All paths, game addresses, lifted function names and labels are redacted; only thread names, priorities, core numbers and counters remain. **Default 250 ms (Linux main, non-dma-buf guest window)** ```log escapes: 9; crashes: 0 [hos PreActorModelCompose] vcores: PreActorModelCompose (priority 26) ran without a core after 250.1 ms: core 0: sead::MainThread (16) wait=-; [hos PreActorModelCompose] vcores: PreActorModelCompose (priority 26) ran without a core after 250.1 ms: core 5: ParallelDecompThread (19) wait=-; [hos GeneralFormatAsync_] vcores: GeneralFormatAsync_ (priority 24) ran without a core after 250.0 ms: core 5: ParallelDecompThread (19) wait=-; [hos aglTextureDataMgr::Compaction] vcores: aglTextureDataMgr::Compaction (priority 25) ran without a core after 250.1 ms: core 1: ModuleSystemWorker1 (16) wait=-; poll-hit lines (cap 400): 400, e.g. [hos ParallelDecompThread] poll-hit: ParallelDecompThread kick 2 core 0 [hos ParallelDecompThread] poll-hit: ParallelDecompThread kick 2 core 4 ``` **Progress-aware escape (same arm)** ```log escapes: 2; crashes: 0 [hos BakeProbe] vcores: BakeProbe (priority 28) ran without a core after 272.4 ms: core 1: OpusDecoder (14) wait=-; [hos bake::ModuleMgr] vcores: bake::ModuleMgr (priority 28) ran without a core after 273.3 ms: core 1: OpusDecoder (14) wait=-; ``` **Escapes disabled entirely (1 h threshold), 100 s arm** ```log noesc1: crashes=0 escapes=0 fps=60.0 fps noesc2: crashes=0 escapes=0 fps=60.0 fps ``` **Host stack at the stop (sanitized)** ```text thread entry shared worker loop decompression job entry decompressor <-- holder, >250 ms (no hos_cv_sleep, no host mutex, no FS/GPU/NVN call) ``` **Reproduction (host harness; no game files)** ```sh fresh --user; HOS_VIDEO_LOG=1 HOS_VCORE_LOG=1 HOS_POLL_TRACE=1 HOS_START_AT_ACQUIRE=0 run; wait for "AV1 decoder ready"; sleep 100 append "0:PLUS,1:A,2:A" to HOS_INPUT_LIVE; run 45 s count "ran without a core" in the log ``` --- 🤖 Generated with [opencode](https://opencode.ai) using DeepSeek V4.1 Flash.
mooki000 commented 2026-11-11 16:19:48 +00:00 (Migrated from github.com)

@CIB thanks for the split. Numbers from a second machine for your three holders, then one thing about the rule that I read in the code, and where the three pull requests stand against it.

Machine and method: Arch, RTX 4090, NVIDIA 615.78.08, headless, a fresh profile, Start pressed 95 s into the title. The escape was put at 250 ms with HOS_VCORE_ESCAPE_US=250000 HOS_VCORE_ESCAPE_EQ_US=250000 on builds that already have #2127's 2 s default, so these compare with your 250 ms arms. Holders are counted from main's own escape lines by a script; held times and stacks come from a local diagnostic build.

Your three holders in our logs

runs escapes ParallelDecompThread a holder OpusDecoder nn.npln.*
main's code, 250 ms 10 61 8, in 4 of the runs 0 0
with #2302, 250 ms 3 63 4, in 1 run 0 0
with #2301, 250 ms 3 11 0 0 0
with #2322, 250 ms 6 33 4, in 3 runs 0 2, in 1 run
main now (2 s) 3 0
  • The decompressor: yes, we see it. Each time it had the better priority (18 or 19 against waiters at 23, 24 and 26) and had not been asked. Your reading of vc_acquire is right: the kick goes to a worse holder, or after 2 ms to an equal one, never to a better one (host/hos_thread.c:1385-1399).
  • But here it was not one long hold. At the 2 escapes behind it in the diagnostic runs it had held its core for 0.3 and 1.9 ms, in game code. The core was changing hands among better threads while the worse one waited its 250 ms (your kick 2 lines are that: an equal waiter's time slice). The larger group of the same kind here is BaseProcCreateAndDeleteThread (23): a holder at 20 of the 61, and 0.1 to 2.2 ms into its hold at the 5 escapes the diagnostic run caught. The conclusion is yours either way: the holders are making progress and the worse waiter should wait.
  • OpusDecoder: never a holder at an escape here, in these runs or in any of the 1083 escape lines of all 241 Hookshot logs on this machine.

Added 2026-11-11 18:07 UTC: a later, disturbed run of the local progress-rule experiment has OpusDecoder as the holder at one escape. A decode took 433.6 ms; it and the Presentation Thread were sampled in uninterruptible sleep (D), with load rising from 10.56 to 84.80. The earlier snapshot above remains unchanged; “never” does not describe all subsequent runs.

  • nn.npln.*: 2 escapes of one run, better than the waiter and not asked. In the two stacks we have of nn.npln.Shared it is asleep in WaitAny with the core already given up. The host wait that did keep a core there was fixed on main by 9f42700e (WaitAny's scan under mw_lock). Is your main older than that commit?

Added 2026-11-11 18:07 UTC: CIB confirmed the baseline is 8c92fe3, after 9f42700e. The old WaitAny scan therefore does not explain his report; his guest-mutex case remains distinct from the host samples here. His newer post also reports five escapes over two runs, not only the three late examples discussed below.

One thing about the rule, from the code

hos_vcore_checkpoint is reached at nn::os calls and SVCs, and from a loop's poll only when a kick has set the poll flag (nx2/rtgen.py:29-33, hos_thread.c:1397). So a last_checkpoint written there does not move for a better holder that loops without calling the system and that nobody equal or better has asked. And a thread that wakes from a long wait takes its core with the stamp of the call it went to sleep in: wait_any checkpoints once on entry and then gives the core up and takes it again every 5 ms at most (host/nn_os_sync.c:1137, 1155).

Your three remaining escapes came after 272.4, 273.3 and 358.4 ms, not 250. If the patch does what the sketch says, that can only happen one way: the waiter was kept at its deadline because a holder had checkpointed, and at a later check stood behind a holder that had taken the core in between with a stamp older than the wait. Then OpusDecoder and nn.npln.Shared were not stuck in host code: they had held the core for less than 24 ms and less than 109 ms. That is my inference from your lines and the sketch; I have not seen your patch.

Added 2026-11-11 17:02 UTC: "only one way" was too strong. In a run of my own experiment since (a disturbed run: one core stuck for about 0.7 s, the load rising from 11 to 85) three escapes came 6 to 26 ms after the deadline (256.4, 267.4 and 276.4 ms) without the waiter having been kept at the deadline first. So a late escape alone does not show that the waiter was kept: it can also simply run late. In my build the experiment's extra logging samples each holder's stack and waits up to 50 ms for one that does not answer, and a busy host could delay the waiter the same way. From your three lines alone I can't tell which happened.

If that is what happened, counting "took its core since the waiter started" as progress would close it. I have both variants as a local experiment, my own reading of your sketch and not your code. Without the game (the scheduler block cut out of the source, one waiter, a scripted holder) it behaves like this: when the core passes at 300 ms to a better thread whose last checkpoint is older than the wait, the sketch escapes at 300 ms behind that thread; with a take counted as progress it does not escape. The runs in the game are queued and I will add their counts here.

Correction 2026-11-11 18:07 UTC: counting a take with the static condition last_take >= t0 is not sufficient. One take followed by a blocked holder can keep that condition true indefinitely and prevent the escape. Acquisition/checkpoint progress must expire through a rolling deadline, as CIB now proposes. My static take-counted variant is a local experiment, not a safe production recommendation.

Where the three pull requests stand against your split

  • #2301 and #2322 take away two holders of your second kind: host code with no checkpoint and the core kept for the whole wait (the prompt build 251-357 ms at a first start, the movie decode 100-270 ms). Your rule would still let a waiter escape behind them; with the changes the hold is gone.
  • #2302 bounds whatever escape is left to the thread's next block.
  • With #2301 or #2322 alone, every remaining escape stood behind a better holder (11 of 11, 33 of 33): those are for your rule. So I see the four as complementary and would welcome the rule beside them.

What I can test for you: your patch, as a diff here or a pull request, on main and on main with the three changes: the issue's steps at 250 ms and at 2 s, escapes per run by holder, with how long each holder had held its core, its last checkpoint and its host stack. Linux with NVIDIA only; Windows is yours and the maintainer's.

Not run here: your patch itself, anything on Windows, anything past the first minute after Start.

@CIB thanks for the split. Numbers from a second machine for your three holders, then one thing about the rule that I read in the code, and where the three pull requests stand against it. Machine and method: Arch, RTX 4090, NVIDIA 615.78.08, headless, a fresh profile, Start pressed 95 s into the title. The escape was put at 250 ms with `HOS_VCORE_ESCAPE_US=250000 HOS_VCORE_ESCAPE_EQ_US=250000` on builds that already have #2127's 2 s default, so these compare with your 250 ms arms. Holders are counted from main's own escape lines by a script; held times and stacks come from a local diagnostic build. **Your three holders in our logs** | | runs | escapes | `ParallelDecompThread` a holder | `OpusDecoder` | `nn.npln.*` | |---|---:|---:|---|---|---| | main's code, 250 ms | 10 | 61 | 8, in 4 of the runs | 0 | 0 | | with #2302, 250 ms | 3 | 63 | 4, in 1 run | 0 | 0 | | with #2301, 250 ms | 3 | 11 | 0 | 0 | 0 | | with #2322, 250 ms | 6 | 33 | 4, in 3 runs | 0 | 2, in 1 run | | main now (2 s) | 3 | 0 | | | | - **The decompressor: yes, we see it.** Each time it had the better priority (18 or 19 against waiters at 23, 24 and 26) and had not been asked. Your reading of `vc_acquire` is right: the kick goes to a worse holder, or after 2 ms to an equal one, never to a better one (`host/hos_thread.c:1385-1399`). - **But here it was not one long hold.** At the 2 escapes behind it in the diagnostic runs it had held its core for 0.3 and 1.9 ms, in game code. The core was changing hands among better threads while the worse one waited its 250 ms (your `kick 2` lines are that: an equal waiter's time slice). The larger group of the same kind here is `BaseProcCreateAndDeleteThread` (23): a holder at 20 of the 61, and 0.1 to 2.2 ms into its hold at the 5 escapes the diagnostic run caught. The conclusion is yours either way: the holders are making progress and the worse waiter should wait. - **`OpusDecoder`: never** a holder at an escape here, in these runs or in any of the 1083 escape lines of all 241 Hookshot logs on this machine. *Added 2026-11-11 18:07 UTC: a later, disturbed run of the local progress-rule experiment has `OpusDecoder` as the holder at one escape. A decode took 433.6 ms; it and the Presentation Thread were sampled in uninterruptible sleep (D), with load rising from 10.56 to 84.80. The earlier snapshot above remains unchanged; “never” does not describe all subsequent runs.* - **`nn.npln.*`: 2 escapes of one run**, better than the waiter and not asked. In the two stacks we have of `nn.npln.Shared` it is asleep in `WaitAny` with the core already given up. The host wait that did keep a core there was fixed on main by 9f42700e (WaitAny's scan under `mw_lock`). Is your main older than that commit? *Added 2026-11-11 18:07 UTC: CIB confirmed the baseline is `8c92fe3`, after `9f42700e`. The old WaitAny scan therefore does not explain his report; his guest-mutex case remains distinct from the host samples here. His newer post also reports five escapes over two runs, not only the three late examples discussed below.* **One thing about the rule, from the code** `hos_vcore_checkpoint` is reached at nn::os calls and SVCs, and from a loop's poll only when a kick has set the poll flag (`nx2/rtgen.py:29-33`, `hos_thread.c:1397`). So a `last_checkpoint` written there does not move for a better holder that loops without calling the system and that nobody equal or better has asked. And a thread that wakes from a long wait takes its core with the stamp of the call it went to sleep in: `wait_any` checkpoints once on entry and then gives the core up and takes it again every 5 ms at most (`host/nn_os_sync.c:1137`, `1155`). Your three remaining escapes came after 272.4, 273.3 and 358.4 ms, not 250. If the patch does what the sketch says, that can only happen one way: the waiter was kept at its deadline because a holder had checkpointed, and at a later check stood behind a holder that had taken the core in between with a stamp older than the wait. Then `OpusDecoder` and `nn.npln.Shared` were not stuck in host code: they had held the core for less than 24 ms and less than 109 ms. That is my inference from your lines and the sketch; I have not seen your patch. *Added 2026-11-11 17:02 UTC: "only one way" was too strong. In a run of my own experiment since (a disturbed run: one core stuck for about 0.7 s, the load rising from 11 to 85) three escapes came 6 to 26 ms after the deadline (256.4, 267.4 and 276.4 ms) without the waiter having been kept at the deadline first. So a late escape alone does not show that the waiter was kept: it can also simply run late. In my build the experiment's extra logging samples each holder's stack and waits up to 50 ms for one that does not answer, and a busy host could delay the waiter the same way. From your three lines alone I can't tell which happened.* If that is what happened, counting "took its core since the waiter started" as progress would close it. I have both variants as a local experiment, my own reading of your sketch and not your code. Without the game (the scheduler block cut out of the source, one waiter, a scripted holder) it behaves like this: when the core passes at 300 ms to a better thread whose last checkpoint is older than the wait, the sketch escapes at 300 ms behind that thread; with a take counted as progress it does not escape. The runs in the game are queued and I will add their counts here. *Correction 2026-11-11 18:07 UTC: counting a take with the static condition `last_take >= t0` is not sufficient. One take followed by a blocked holder can keep that condition true indefinitely and prevent the escape. Acquisition/checkpoint progress must expire through a rolling deadline, as CIB now proposes. My static take-counted variant is a local experiment, not a safe production recommendation.* **Where the three pull requests stand against your split** - #2301 and #2322 take away two holders of your second kind: host code with no checkpoint and the core kept for the whole wait (the prompt build 251-357 ms at a first start, the movie decode 100-270 ms). Your rule would still let a waiter escape behind them; with the changes the hold is gone. - #2302 bounds whatever escape is left to the thread's next block. - With #2301 or #2322 alone, every remaining escape stood behind a better holder (11 of 11, 33 of 33): those are for your rule. So I see the four as complementary and would welcome the rule beside them. **What I can test for you:** your patch, as a diff here or a pull request, on main and on main with the three changes: the issue's steps at 250 ms and at 2 s, escapes per run by holder, with how long each holder had held its core, its last checkpoint and its host stack. Linux with NVIDIA only; Windows is yours and the maintainer's. Not run here: your patch itself, anything on Windows, anything past the first minute after Start.
CIB commented 2026-11-11 17:27:44 +00:00 (Migrated from github.com)

@mooki000 thanks — this is the split we needed, and the code reading is the useful part. I went through it against our build and our patch. Below: what we agree with, one place where you are right and I was wrong, and the change I'd make to the rule.

Baseline: our main is not older than 9f42700e. Our runs are on main at 8c92fe3 (2026-11-10 20:00Z, v2.0.11-742-g8c92fe3, tree dirty with local instrumentation), 307 commits after 9f42700e. So the nn.npln holder we see is not the WaitAny/mw_lock scan that commit fixed.

On your table. The ParallelDecompThread rows read the same way to us: 8 of 61 on main's code, 4 of 63 with #2302, 4 of 33 with #2322, and 0 of 11 with #2301. The two differences we cannot explain yet are yours: OpusDecoder is a holder twice in our prog1 and zero times in your 1083 escape lines over 241 logs, and nn.npln.* is a holder three times in our prog2 against twice in one run of yours. Our samples are small (two runs, 5 escapes), ours are headless and stop ~45 s after Start, and the route differs; I don't have a better explanation than that, and it is worth chasing.

What we have for nn.npln is a guest recursive mutex in the fiber coordinator, not the WaitAny scan: nn.npln.Worker waits 10 s on a mutex held by nn.npln.Worker (count 2, one waiter), fibers in 0x8504b440; the prog2 lines name nn.npln.Worker/Shared (16) with lr=0x8504bf28. We have no host stack at those escapes — the host-stack capture in my 15:24 post was the decompressor's, I should have said that — so whether an away/back can remove it is open. OpusDecoder (14, lr=0x836f3398) is the holder at prog1's two escapes; we have no host stack at an Opus escape either. nn_codec.c's decode has no away/back, so it is a candidate, not a proven host hold. Correcting my 15:24 sentence: "the only escapes left are non-polling host holders" was too strong.

The decompressor: you are right, and I was wrong. A64_POLL is, in effect, if (c->poll) { c->poll = 0; a64_poll_cur(); }, and c->poll is set only with a kick (vc_acquire sets vc_kick and the context's poll flag together). A better holder that nobody asked reaches no checkpoint in a guest loop, exactly as you wrote. My patch stores the stamp at the top of hos_vcore_checkpoint, which only helps once the checkpoint is reached. So the loss of the decompressor escape in prog1/prog2 is not proof that the rule sees ordinary polls: the holder can have been kicked by another waiter's equal-slice (kick 2 — you read those lines right) or passed an nn::os call during the wait. The logs cannot tell which, and the rule as it stands is weaker than I described.

To make it robust, two scheduler-side options (no lift change, no game code):

  • at the escape deadline, send a probe kick to the better holders on the allowed cores: they reach the poll, the checkpoint stamps progress, and they do not yield (a new kick value, cleared in hos_vcore_checkpoint before the vc_yield branch);
  • or stamp progress in the poll itself, before the c->poll test (a generated-code change in rtgen.py).

I prefer the first: it leaves generated code alone, and it only probes when a waiter is actually at its deadline. Pair it with the rolling deadline below — the probe advances last_progress, so a holder that polls once and then blocks in host code still trips the timeout a full window later. Without that pairing, one probe-triggered poll would satisfy the current static test forever.

Late escapes. Our check is a static "any allowed holder's vc_last_poll >= t0" evaluated each loop, with no reset. A holder granted after t0 carries the stamp of the call it slept in, so at a later evaluation the waiter can see no progress and escape. That matches your inference. The two prog runs have five escapes, not three: 252.2, 259.7, 272.4, 273.3 and 358.4 ms (my earlier "2" was prog1 alone; prog2 adds three). Your 17:02 addendum is right too: the logs cannot separate kept-then-stale from a deadline evaluation that simply ran late. The next build logs each deadline evaluation (time, holder, stamp) so the lines separate by themselves.

"Took its core" as progress: yes, but as a rolling deadline. If last_take >= t0 is OR'ed in as a second static test, a holder that takes a core after t0 and then blocks in host code keeps progressed true forever and the deadlock breaker never fires. What I'd implement:

int64_t last_progress = t0;
...
for each allowed holder h:
    last_progress = max(last_progress, h->vc_last_poll, h->vc_last_take);
if (now - last_progress > timeout) escape();

last_progress survives holder changes, so your 300 ms handoff resets the window (no escape at 300) while a host-blocked holder still trips it a full timeout later. Same outcome as your experiment for the handoff case; the difference is only in the stuck case. Tell me if you see a hole.

Your other points. BaseProcCreateAndDeleteThread as holder in 20 of the 61 escapes, and 0.1–2.2 ms into its hold at the 5 escapes the diagnostic run caught, is exactly the class the rule should keep waiting; so are your 11/11 with #2301 and 33/33 with #2322 behind a better holder. "Not one long hold" matches ours: our escaping waiters are worse threads waiting while the core moves among better ones. I read the three changes as complementary to the rule — #2301/#2322 remove two host holds, #2302 bounds an escape to one block, the rule decides whether a progressing holder should be escaped at all — and none of them is a substitute for another.

Your PRs. #2301 (16:35Z), #2322 (16:36Z) and #2302 (16:40Z) are merged. #2380 restores the two vcore tests; its reported run is 373 passed, 2 failed, 10 skipped, the same two machine-local failures as before. "Two to three minutes apart" for #2042 vs #2301/#2322, not two. #2387 is open, head 5044fa98 on base 9a3eb902 (your note says b29967bf; main has moved on), and its reasoning looks right to us — vc_grant runs on the giver, so back is the right place.

Testing: yes, please. I'll paste the scheduler hunk and the host-only test into this issue for you to run. I'm not opening a PR while this area is moving this fast — a PR would be stale before review, and the evidence is what I want to keep here. If you run it: current main (it already contains #2301/#2302/#2322), main + #2387, and main + #2387 + the rule, at 250 ms and at the 2 s default, with escapes per holder, how long each had held, its last checkpoint and its host stack. Linux/NVIDIA is enough. Our own runs stop about 45 s after Start, and we have not built your three merges or #2387 either, so every arm in that matrix is new for us.

Separate: these runs use the anonymous guest window (HOS_GUEST_MEMFD=0). The earlier dma-buf attribution of the main-only cue crash is withdrawn; NVIDIA's dma-buf import refusal (#2339/#2340) is a separate, real issue.


🤖 Generated with opencode using DeepSeek V4.1 Flash.

@mooki000 thanks — this is the split we needed, and the code reading is the useful part. I went through it against our build and our patch. Below: what we agree with, one place where you are right and I was wrong, and the change I'd make to the rule. **Baseline: our main is not older than `9f42700e`.** Our runs are on main at `8c92fe3` (2026-11-10 20:00Z, `v2.0.11-742-g8c92fe3`, tree dirty with local instrumentation), 307 commits after `9f42700e`. So the `nn.npln` holder we see is not the WaitAny/`mw_lock` scan that commit fixed. **On your table.** The `ParallelDecompThread` rows read the same way to us: 8 of 61 on main's code, 4 of 63 with #2302, 4 of 33 with #2322, and 0 of 11 with #2301. The two differences we cannot explain yet are yours: `OpusDecoder` is a holder twice in our prog1 and zero times in your 1083 escape lines over 241 logs, and `nn.npln.*` is a holder three times in our prog2 against twice in one run of yours. Our samples are small (two runs, 5 escapes), ours are headless and stop ~45 s after Start, and the route differs; I don't have a better explanation than that, and it is worth chasing. What we have for `nn.npln` is a **guest recursive mutex** in the fiber coordinator, not the WaitAny scan: `nn.npln.Worker` waits 10 s on a mutex held by `nn.npln.Worker` (count 2, one waiter), fibers in `0x8504b440`; the prog2 lines name `nn.npln.Worker`/`Shared` (16) with `lr=0x8504bf28`. We have no host stack at those escapes — the host-stack capture in my 15:24 post was the decompressor's, I should have said that — so whether an away/back can remove it is open. `OpusDecoder` (14, `lr=0x836f3398`) is the holder at prog1's two escapes; we have no host stack at an Opus escape either. `nn_codec.c`'s decode has no away/back, so it is a candidate, not a proven host hold. Correcting my 15:24 sentence: "the only escapes left are non-polling host holders" was too strong. **The decompressor: you are right, and I was wrong.** `A64_POLL` is, in effect, `if (c->poll) { c->poll = 0; a64_poll_cur(); }`, and `c->poll` is set only with a kick (`vc_acquire` sets `vc_kick` and the context's poll flag together). A better holder that nobody asked reaches no checkpoint in a guest loop, exactly as you wrote. My patch stores the stamp at the top of `hos_vcore_checkpoint`, which only helps once the checkpoint is reached. So the loss of the decompressor escape in prog1/prog2 is **not** proof that the rule sees ordinary polls: the holder can have been kicked by another waiter's equal-slice (`kick 2` — you read those lines right) or passed an `nn::os` call during the wait. The logs cannot tell which, and the rule as it stands is weaker than I described. To make it robust, two scheduler-side options (no lift change, no game code): - at the escape deadline, send a **probe kick** to the better holders on the allowed cores: they reach the poll, the checkpoint stamps progress, and they do not yield (a new kick value, cleared in `hos_vcore_checkpoint` before the `vc_yield` branch); - or stamp progress in the poll itself, before the `c->poll` test (a generated-code change in `rtgen.py`). I prefer the first: it leaves generated code alone, and it only probes when a waiter is actually at its deadline. Pair it with the rolling deadline below — the probe advances `last_progress`, so a holder that polls once and then blocks in host code still trips the timeout a full window later. Without that pairing, one probe-triggered poll would satisfy the current static test forever. **Late escapes.** Our check is a static "any allowed holder's `vc_last_poll >= t0`" evaluated each loop, with no reset. A holder granted after `t0` carries the stamp of the call it slept in, so at a later evaluation the waiter can see no progress and escape. That matches your inference. The two prog runs have five escapes, not three: 252.2, 259.7, 272.4, 273.3 and 358.4 ms (my earlier "2" was prog1 alone; prog2 adds three). Your 17:02 addendum is right too: the logs cannot separate kept-then-stale from a deadline evaluation that simply ran late. The next build logs each deadline evaluation (time, holder, stamp) so the lines separate by themselves. **"Took its core" as progress: yes, but as a rolling deadline.** If `last_take >= t0` is OR'ed in as a second static test, a holder that takes a core after `t0` and then blocks in host code keeps `progressed` true forever and the deadlock breaker never fires. What I'd implement: ```c int64_t last_progress = t0; ... for each allowed holder h: last_progress = max(last_progress, h->vc_last_poll, h->vc_last_take); if (now - last_progress > timeout) escape(); ``` `last_progress` survives holder changes, so your 300 ms handoff resets the window (no escape at 300) while a host-blocked holder still trips it a full timeout later. Same outcome as your experiment for the handoff case; the difference is only in the stuck case. Tell me if you see a hole. **Your other points.** `BaseProcCreateAndDeleteThread` as holder in 20 of the 61 escapes, and 0.1–2.2 ms into its hold at the 5 escapes the diagnostic run caught, is exactly the class the rule should keep waiting; so are your 11/11 with #2301 and 33/33 with #2322 behind a better holder. "Not one long hold" matches ours: our escaping waiters are worse threads waiting while the core moves among better ones. I read the three changes as complementary to the rule — #2301/#2322 remove two host holds, #2302 bounds an escape to one block, the rule decides whether a progressing holder should be escaped at all — and none of them is a substitute for another. **Your PRs.** #2301 (16:35Z), #2322 (16:36Z) and #2302 (16:40Z) are merged. #2380 restores the two vcore tests; its reported run is 373 passed, 2 failed, 10 skipped, the same two machine-local failures as before. "Two to three minutes apart" for #2042 vs #2301/#2322, not two. #2387 is open, head `5044fa98` on base `9a3eb902` (your note says `b29967bf`; main has moved on), and its reasoning looks right to us — `vc_grant` runs on the giver, so `back` is the right place. **Testing: yes, please.** I'll paste the scheduler hunk and the host-only test into this issue for you to run. I'm not opening a PR while this area is moving this fast — a PR would be stale before review, and the evidence is what I want to keep here. If you run it: current main (it already contains #2301/#2302/#2322), main + #2387, and main + #2387 + the rule, at 250 ms and at the 2 s default, with escapes per holder, how long each had held, its last checkpoint and its host stack. Linux/NVIDIA is enough. Our own runs stop about 45 s after Start, and we have not built your three merges or #2387 either, so every arm in that matrix is new for us. **Separate:** these runs use the anonymous guest window (`HOS_GUEST_MEMFD=0`). The earlier dma-buf attribution of the main-only cue crash is withdrawn; NVIDIA's dma-buf import refusal (#2339/#2340) is a separate, real issue. --- 🤖 Generated with [opencode](https://opencode.ai) using DeepSeek V4.1 Flash.
CIB commented 2026-11-11 17:31:57 +00:00 (Migrated from github.com)

The hunk and the test, as promised. Both are host-only; no game code, assets or generated C.

The hunk is the version that ran in prog1/prog2: hos_vcore_checkpoint records vc_last_poll, and a waiter at its deadline does not escape while any holder on one of its allowed cores has a stamp at or after its start. It is deliberately the simple version — it does not contain the rolling deadline or the probe kick from my previous comment. It is against current main (so it composes with #2302's vc_escaped), and the test fails on main without it (the field does not exist) and passes with it, with g++ and clang++:

--- a/host/hos_thread.c
+++ b/host/hos_thread.c
@@ -40,6 +40,7 @@
     int vc_held, vc_last, vc_queued, vc_escaped;
     volatile int vc_kick;
     int64_t vc_since;
+    int64_t vc_last_poll;   /* (progress) the last time this thread reached a checkpoint */
     CONDITION_VARIABLE vc_cv;
     /* (hookshot) fibers: the context running now (NULL: ctx, the thread's own), the thread's host fiber once it
        switched to a guest fiber */
@@ -1414,7 +1415,13 @@
             break;
         }
         const int64_t waited = hos_now_ns() - t0;
-        if (waited > (worst ? esc_better : esc_eq)) {
+        int progressed = 0;
+        for (int k = 0; k < vc_cores() && !progressed; k++) {
+            HosThread *h = vc_owner[k];
+            if ((m >> k & 1) && h && __atomic_load_n(&h->vc_last_poll, __ATOMIC_RELAXED) >= t0)
+                progressed = 1;   /* a holder on a core the waiter may use has checkpointed since the wait began */
+        }
+        if (!progressed && waited > (worst ? esc_better : esc_eq)) {
             vc_dequeue(t);
             t->vc_escaped = 1;
             vc_st.escapes++;
@@ -1491,6 +1498,7 @@
    stall dump of a thread spinning in a polled loop shows such older registers for the same reason. */
 void hos_vcore_checkpoint(void) {
     HosThread *t = cur;
+    if (t) __atomic_store_n(&t->vc_last_poll, hos_now_ns(), __ATOMIC_RELAXED);
     if (t && t->vc_kick) vc_yield(t, t->vc_kick == 1);
 }
 void hos_vcore_yield(void) {

tests/test_vcore_progress.py (host-only; compiles the actual HosThread, hos_thread_core and the virtual-core block with a single-threaded harness):

"""A waiter for a virtual core does not escape while a holder on one of the waiter's allowed cores has reached a
checkpoint since the wait began (host/hos_thread.c vc_acquire; issue 1929). The actual HosThread, hos_thread_core and
the whole virtual-core block (from vc_lock to hos_cv_sleep) are compiled with a single-threaded harness: a clock the
waits advance, and a holder whose checkpoint stamp the harness writes, as another thread's hos_vcore_checkpoint would.
Before, a worse waiter escaped at its deadline whatever the holder was doing; the escape is the deadlock breaker, and
it is only meant to fire when no holder on its cores has made progress.

python -I -B tests/test_vcore_progress.py [--cxx clang++]
"""
import argparse
import os
from pathlib import Path
import shutil
import subprocess
import tempfile

RUN_ALL_ARGS = ["--cxx", "{cxx}"]

ROOT = Path(__file__).resolve().parents[1]


def block(source, marker):
    start = source.index(marker)
    end = source.index("{", start) + 1
    depth = 1
    while depth:
        depth += (source[end] == "{") - (source[end] == "}")
        end += 1
    return source[start:end] + (";" if source[end:end + 1] == ";" else "")


HARNESS = r"""
#include <stdarg.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
typedef void *HANDLE;
typedef int BOOL;
typedef unsigned long DWORD;
typedef struct { void *p; } SRWLOCK, *PSRWLOCK;
typedef struct { void *p; } CONDITION_VARIABLE, *PCONDITION_VARIABLE;
#define SRWLOCK_INIT {0}
#define HOS_TLS_SLOTS 64
typedef struct { uint64_t x[32]; volatile int poll; } Ctx;
static int64_t now_ns = 1000000000;
static int64_t hos_now_ns(void) { return now_ns; }
static int escapes_logged;
static void hos_log(const char *fmt, ...) { if (strstr(fmt, "ran without a core")) escapes_logged++; }
static int hos_cores(void) { return 2; }
@STRUCT@
static HosThread *cur;
static HosThread *stamp_holder;
static int stamp_at;
static int64_t stamp_value;
static int escapes_at_stamp;
static int sleeps;
/* (no other thread runs here: a wait for a core only lets the clock go on, 1 ms a look, as the real one times out;
   at stamp_at the harness writes the holder's checkpoint stamp, as another thread's hos_vcore_checkpoint would) */
static BOOL SleepConditionVariableSRW(PCONDITION_VARIABLE cv, PSRWLOCK lk, DWORD ms, unsigned long f) {
    (void)cv, (void)lk, (void)f;
    now_ns += (int64_t)(ms ? ms : 1) * 1000000;
    sleeps++;
    if (stamp_holder && sleeps == stamp_at) {
        escapes_at_stamp = escapes_logged;
        stamp_holder->vc_last_poll = stamp_value;
    }
    return 1;
}
static void WakeConditionVariable(PCONDITION_VARIABLE cv) { (void)cv; }
static void AcquireSRWLockExclusive(PSRWLOCK l) { (void)l; }
static void ReleaseSRWLockExclusive(PSRWLOCK l) { (void)l; }
@CORE@
@VCORES@
static int bad;
static void check(int ok, const char *what) { if (!ok) { printf("FAIL: %s\n", what); bad = 1; } }
static void thread(HosThread *t, const char *name, int prio, uint64_t mask) {
    memset(t, 0, sizeof *t);
    snprintf(t->name, sizeof t->name, "%s", name);
    t->priority = prio, t->core_mask = mask, t->ideal_core = -2;
}
static void hold(HosThread *t) { cur = t; hos_vcore_back(); }
int main(void) {
    setenv("HOS_VCORE_ESCAPE_US", "250000", 1);
    setenv("HOS_VCORE_ESCAPE_EQ_US", "250000", 1);
    HosThread h0, h1, t;

    /* 1. a holder that checkpoints after the wait starts keeps the worse waiter waiting; it escapes once that stops */
    thread(&h0, "holder0", 16, 1), thread(&h1, "holder1", 16, 2), thread(&t, "waiter", 26, 3);
    hold(&h0), hold(&h1);
    check(h0.vc_held && h1.vc_held, "the two holders hold the two cores");
    escapes_logged = 0, sleeps = 0;
    h0.vc_last_poll = now_ns + 1000000;   /* it checkpointed just after the wait starts */
    stamp_holder = &h0, stamp_at = 400, stamp_value = now_ns - 2000000000;   /* 400 ms later it has not since */
    cur = &t; hos_vcore_back();
    check(escapes_at_stamp == 0, "a waiter does not escape while a holder on its cores is making progress");
    check(escapes_logged == 1 && !t.vc_held, "and escapes once that holder stops making progress");
    stamp_holder = NULL;

    /* 2. a holder that has not checkpointed since the wait began lets it escape at the deadline */
    thread(&h0, "holder0", 16, 1), thread(&h1, "holder1", 16, 2), thread(&t, "waiter", 26, 3);
    memset(vc_owner, 0, sizeof vc_owner);
    hold(&h0), hold(&h1);
    h0.vc_last_poll = now_ns - 2000000000, h1.vc_last_poll = now_ns - 2000000000;
    escapes_logged = 0, sleeps = 0;
    cur = &t; hos_vcore_back();
    check(escapes_logged == 1 && !t.vc_held, "a waiter escapes behind a holder that has not checkpointed");
    check(sleeps >= 250, "only after its 250 ms deadline");

    /* 3. only holders of the cores the waiter may use count */
    thread(&h0, "holder0", 16, 1), thread(&h1, "holder1", 16, 2), thread(&t, "waiter", 26, 1);
    memset(vc_owner, 0, sizeof vc_owner);
    hold(&h0), hold(&h1);
    h0.vc_last_poll = now_ns - 2000000000;   /* the waiter's core: stale */
    h1.vc_last_poll = now_ns + 1000000;      /* not its core: fresh */
    escapes_logged = 0, sleeps = 0;
    stamp_holder = NULL;
    cur = &t; hos_vcore_back();
    check(escapes_logged == 1 && !t.vc_held, "a fresh holder on a core the waiter may not use is not progress");

    /* 4. the checkpoint records when a thread last reached one */
    thread(&h0, "holder0", 16, 1);
    cur = &h0;
    h0.vc_kick = 0;
    h0.vc_last_poll = 0;
    now_ns += 5000000;
    hos_vcore_checkpoint();
    check(h0.vc_last_poll == now_ns, "a checkpoint records the time it was reached");

    if (!bad) puts("ok");
    return bad;
}
"""


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--cxx", default="clang++")
    parser.add_argument("--source", type=Path, default=ROOT / "host/hos_thread.c")
    args = parser.parse_args()
    src = args.source.read_text(encoding="utf-8")
    start = src.index("static SRWLOCK vc_lock = SRWLOCK_INIT;")
    end = src.index("\n#else", src.index("int hos_cv_sleep(void *cv, void *lk, unsigned ms) {", start))
    fixture = (HARNESS.replace("@STRUCT@", block(src, "struct HosThread {") + "\ntypedef struct HosThread HosThread;")
               .replace("@CORE@", block(src, "int hos_thread_core(HosThread *t) {"))
               .replace("@VCORES@", src[start:end]))
    compiler = shutil.which(args.cxx)
    if not compiler:
        raise SystemExit("C++ compiler not found: " + args.cxx)
    with tempfile.TemporaryDirectory(prefix="vcore-progress-") as temporary:
        folder = Path(temporary)
        (folder / "fixture.cpp").write_text(fixture, encoding="utf-8")
        exe = folder / ("fixture.exe" if os.name == "nt" else "fixture")
        subprocess.run([compiler, "-std=c++17", "-O1", "-w", str(folder / "fixture.cpp"), "-o", str(exe)], check=True,
                       timeout=120)
        result = subprocess.run([str(exe)], capture_output=True, text=True, timeout=30)
        print(result.stdout, end="")
        if result.returncode or "ok" not in result.stdout:
            raise SystemExit("a waiter escapes while a holder on one of its cores is making progress (issue 1929)")


if __name__ == "__main__":
    main()

Run it after applying the hunk:

python -I -B tests/test_vcore_progress.py [--cxx clang++]

It checks that a worse waiter does not escape while a holder on its cores has checkpointed since the wait began, and escapes once that holder stops; that it escapes at ~250 ms behind a holder that has not checkpointed since the wait began; that a fresh holder on a core outside the waiter's mask does not count; and that hos_vcore_checkpoint records the time it is reached. The harness advances a fake clock and writes the holder's stamp where another thread's checkpoint would; it runs no game code and no threads. It also passes with the rolling-deadline change I proposed, because it only asserts that progress delays the escape and that the escape still happens once progress stops.


🤖 Generated with opencode using DeepSeek V4.1 Flash.

The hunk and the test, as promised. Both are host-only; no game code, assets or generated C. The hunk is the version that ran in prog1/prog2: `hos_vcore_checkpoint` records `vc_last_poll`, and a waiter at its deadline does not escape while any holder on one of its allowed cores has a stamp at or after its start. It is deliberately the simple version — it does **not** contain the rolling deadline or the probe kick from my previous comment. It is against current main (so it composes with #2302's `vc_escaped`), and the test fails on main without it (the field does not exist) and passes with it, with g++ and clang++: ```diff --- a/host/hos_thread.c +++ b/host/hos_thread.c @@ -40,6 +40,7 @@ int vc_held, vc_last, vc_queued, vc_escaped; volatile int vc_kick; int64_t vc_since; + int64_t vc_last_poll; /* (progress) the last time this thread reached a checkpoint */ CONDITION_VARIABLE vc_cv; /* (hookshot) fibers: the context running now (NULL: ctx, the thread's own), the thread's host fiber once it switched to a guest fiber */ @@ -1414,7 +1415,13 @@ break; } const int64_t waited = hos_now_ns() - t0; - if (waited > (worst ? esc_better : esc_eq)) { + int progressed = 0; + for (int k = 0; k < vc_cores() && !progressed; k++) { + HosThread *h = vc_owner[k]; + if ((m >> k & 1) && h && __atomic_load_n(&h->vc_last_poll, __ATOMIC_RELAXED) >= t0) + progressed = 1; /* a holder on a core the waiter may use has checkpointed since the wait began */ + } + if (!progressed && waited > (worst ? esc_better : esc_eq)) { vc_dequeue(t); t->vc_escaped = 1; vc_st.escapes++; @@ -1491,6 +1498,7 @@ stall dump of a thread spinning in a polled loop shows such older registers for the same reason. */ void hos_vcore_checkpoint(void) { HosThread *t = cur; + if (t) __atomic_store_n(&t->vc_last_poll, hos_now_ns(), __ATOMIC_RELAXED); if (t && t->vc_kick) vc_yield(t, t->vc_kick == 1); } void hos_vcore_yield(void) { ``` `tests/test_vcore_progress.py` (host-only; compiles the actual `HosThread`, `hos_thread_core` and the virtual-core block with a single-threaded harness): ```python """A waiter for a virtual core does not escape while a holder on one of the waiter's allowed cores has reached a checkpoint since the wait began (host/hos_thread.c vc_acquire; issue 1929). The actual HosThread, hos_thread_core and the whole virtual-core block (from vc_lock to hos_cv_sleep) are compiled with a single-threaded harness: a clock the waits advance, and a holder whose checkpoint stamp the harness writes, as another thread's hos_vcore_checkpoint would. Before, a worse waiter escaped at its deadline whatever the holder was doing; the escape is the deadlock breaker, and it is only meant to fire when no holder on its cores has made progress. python -I -B tests/test_vcore_progress.py [--cxx clang++] """ import argparse import os from pathlib import Path import shutil import subprocess import tempfile RUN_ALL_ARGS = ["--cxx", "{cxx}"] ROOT = Path(__file__).resolve().parents[1] def block(source, marker): start = source.index(marker) end = source.index("{", start) + 1 depth = 1 while depth: depth += (source[end] == "{") - (source[end] == "}") end += 1 return source[start:end] + (";" if source[end:end + 1] == ";" else "") HARNESS = r""" #include <stdarg.h> #include <stdint.h> #include <stdio.h> #include <stdlib.h> #include <string.h> typedef void *HANDLE; typedef int BOOL; typedef unsigned long DWORD; typedef struct { void *p; } SRWLOCK, *PSRWLOCK; typedef struct { void *p; } CONDITION_VARIABLE, *PCONDITION_VARIABLE; #define SRWLOCK_INIT {0} #define HOS_TLS_SLOTS 64 typedef struct { uint64_t x[32]; volatile int poll; } Ctx; static int64_t now_ns = 1000000000; static int64_t hos_now_ns(void) { return now_ns; } static int escapes_logged; static void hos_log(const char *fmt, ...) { if (strstr(fmt, "ran without a core")) escapes_logged++; } static int hos_cores(void) { return 2; } @STRUCT@ static HosThread *cur; static HosThread *stamp_holder; static int stamp_at; static int64_t stamp_value; static int escapes_at_stamp; static int sleeps; /* (no other thread runs here: a wait for a core only lets the clock go on, 1 ms a look, as the real one times out; at stamp_at the harness writes the holder's checkpoint stamp, as another thread's hos_vcore_checkpoint would) */ static BOOL SleepConditionVariableSRW(PCONDITION_VARIABLE cv, PSRWLOCK lk, DWORD ms, unsigned long f) { (void)cv, (void)lk, (void)f; now_ns += (int64_t)(ms ? ms : 1) * 1000000; sleeps++; if (stamp_holder && sleeps == stamp_at) { escapes_at_stamp = escapes_logged; stamp_holder->vc_last_poll = stamp_value; } return 1; } static void WakeConditionVariable(PCONDITION_VARIABLE cv) { (void)cv; } static void AcquireSRWLockExclusive(PSRWLOCK l) { (void)l; } static void ReleaseSRWLockExclusive(PSRWLOCK l) { (void)l; } @CORE@ @VCORES@ static int bad; static void check(int ok, const char *what) { if (!ok) { printf("FAIL: %s\n", what); bad = 1; } } static void thread(HosThread *t, const char *name, int prio, uint64_t mask) { memset(t, 0, sizeof *t); snprintf(t->name, sizeof t->name, "%s", name); t->priority = prio, t->core_mask = mask, t->ideal_core = -2; } static void hold(HosThread *t) { cur = t; hos_vcore_back(); } int main(void) { setenv("HOS_VCORE_ESCAPE_US", "250000", 1); setenv("HOS_VCORE_ESCAPE_EQ_US", "250000", 1); HosThread h0, h1, t; /* 1. a holder that checkpoints after the wait starts keeps the worse waiter waiting; it escapes once that stops */ thread(&h0, "holder0", 16, 1), thread(&h1, "holder1", 16, 2), thread(&t, "waiter", 26, 3); hold(&h0), hold(&h1); check(h0.vc_held && h1.vc_held, "the two holders hold the two cores"); escapes_logged = 0, sleeps = 0; h0.vc_last_poll = now_ns + 1000000; /* it checkpointed just after the wait starts */ stamp_holder = &h0, stamp_at = 400, stamp_value = now_ns - 2000000000; /* 400 ms later it has not since */ cur = &t; hos_vcore_back(); check(escapes_at_stamp == 0, "a waiter does not escape while a holder on its cores is making progress"); check(escapes_logged == 1 && !t.vc_held, "and escapes once that holder stops making progress"); stamp_holder = NULL; /* 2. a holder that has not checkpointed since the wait began lets it escape at the deadline */ thread(&h0, "holder0", 16, 1), thread(&h1, "holder1", 16, 2), thread(&t, "waiter", 26, 3); memset(vc_owner, 0, sizeof vc_owner); hold(&h0), hold(&h1); h0.vc_last_poll = now_ns - 2000000000, h1.vc_last_poll = now_ns - 2000000000; escapes_logged = 0, sleeps = 0; cur = &t; hos_vcore_back(); check(escapes_logged == 1 && !t.vc_held, "a waiter escapes behind a holder that has not checkpointed"); check(sleeps >= 250, "only after its 250 ms deadline"); /* 3. only holders of the cores the waiter may use count */ thread(&h0, "holder0", 16, 1), thread(&h1, "holder1", 16, 2), thread(&t, "waiter", 26, 1); memset(vc_owner, 0, sizeof vc_owner); hold(&h0), hold(&h1); h0.vc_last_poll = now_ns - 2000000000; /* the waiter's core: stale */ h1.vc_last_poll = now_ns + 1000000; /* not its core: fresh */ escapes_logged = 0, sleeps = 0; stamp_holder = NULL; cur = &t; hos_vcore_back(); check(escapes_logged == 1 && !t.vc_held, "a fresh holder on a core the waiter may not use is not progress"); /* 4. the checkpoint records when a thread last reached one */ thread(&h0, "holder0", 16, 1); cur = &h0; h0.vc_kick = 0; h0.vc_last_poll = 0; now_ns += 5000000; hos_vcore_checkpoint(); check(h0.vc_last_poll == now_ns, "a checkpoint records the time it was reached"); if (!bad) puts("ok"); return bad; } """ def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--cxx", default="clang++") parser.add_argument("--source", type=Path, default=ROOT / "host/hos_thread.c") args = parser.parse_args() src = args.source.read_text(encoding="utf-8") start = src.index("static SRWLOCK vc_lock = SRWLOCK_INIT;") end = src.index("\n#else", src.index("int hos_cv_sleep(void *cv, void *lk, unsigned ms) {", start)) fixture = (HARNESS.replace("@STRUCT@", block(src, "struct HosThread {") + "\ntypedef struct HosThread HosThread;") .replace("@CORE@", block(src, "int hos_thread_core(HosThread *t) {")) .replace("@VCORES@", src[start:end])) compiler = shutil.which(args.cxx) if not compiler: raise SystemExit("C++ compiler not found: " + args.cxx) with tempfile.TemporaryDirectory(prefix="vcore-progress-") as temporary: folder = Path(temporary) (folder / "fixture.cpp").write_text(fixture, encoding="utf-8") exe = folder / ("fixture.exe" if os.name == "nt" else "fixture") subprocess.run([compiler, "-std=c++17", "-O1", "-w", str(folder / "fixture.cpp"), "-o", str(exe)], check=True, timeout=120) result = subprocess.run([str(exe)], capture_output=True, text=True, timeout=30) print(result.stdout, end="") if result.returncode or "ok" not in result.stdout: raise SystemExit("a waiter escapes while a holder on one of its cores is making progress (issue 1929)") if __name__ == "__main__": main() ``` Run it after applying the hunk: ``` python -I -B tests/test_vcore_progress.py [--cxx clang++] ``` It checks that a worse waiter does not escape while a holder on its cores has checkpointed since the wait began, and escapes once that holder stops; that it escapes at ~250 ms behind a holder that has not checkpointed since the wait began; that a fresh holder on a core outside the waiter's mask does not count; and that `hos_vcore_checkpoint` records the time it is reached. The harness advances a fake clock and writes the holder's stamp where another thread's checkpoint would; it runs no game code and no threads. It also passes with the rolling-deadline change I proposed, because it only asserts that progress delays the escape and that the escape still happens once progress stops. --- 🤖 Generated with [opencode](https://opencode.ai) using DeepSeek V4.1 Flash.
mooki000 commented 2026-11-11 18:09:43 +00:00 (Migrated from github.com)

@CIB thanks for the baseline and the hunk. Your 8c92fe3 already contains 9f42700e, so that old WaitAny scan does not explain your nn.npln report. I have read your posted hunk/test, but have not run your patch yet.

Here is the completed first batch: one run per arm, with a disturbed run explicitly retained. Later batches are not included. Arch, RTX 4090, NVIDIA 615.78.08, headless, fresh profile, Start at 95 s, 115 s per run, escape thresholds set to 250 ms. These use our local reading of your original static rule, not your posted code, rolling deadline or probe kick.

The baseline is 880cf67f plus diagnostics/rule (76326cba); the other build adds #2301/#2302/#2322 (fb2b393e). Those three fixes have since merged, but these are not fresh builds of current main or of #2387.

Build / rule Escapes Escapes later than 255 ms Waiters kept at deadline Later granted a core Longest wait for a core
baseline, off 6 0 0 0 250.9 ms
baseline, static progress rule 2 2 3 1 736.3 ms
three fixes, off 6 0 0 0 250.9 ms
three fixes, static progress rule — disturbed 8 8 10 5 671.4 ms

The numbers are asserted against the saved logs/table, including the individual escape identities:

  • With the rule off, 5 of 6 baseline escapes and all 6 with the three fixes had an allowed holder that had checkpointed since the wait began. With the static rule on, no escape in that category remained in either run. This small sample is not a general performance claim.
  • The baseline rule-on escapes were at 569.4 and 736.3 ms. nn.npln.Callback/Worker had each only just taken its core (0.0 ms held), with a checkpoint roughly 100 s old; the host samples were in wait_any. This demonstrates a stale-stamp case here, not the cause of your guest-mutex case.
  • OpusDecoder now appears in our data. In the disturbed rule-on run with the three fixes, it was the holder at one escape; one decode took 433.6 ms, and it and the Presentation Thread were sampled in uninterruptible sleep (D). Load rose from 10.56 to 84.80; the cause is not established. Three escapes occurred behind holders that had kept the core throughout the wait without a checkpoint. I have added this qualification to the earlier “never” statement; it was true of the earlier snapshot, not of all subsequent runs.

On the proposed rule: agreed that acquisition must extend a rolling deadline, not satisfy a permanent last_take >= t0 condition. My earlier take-counted variant is an experiment, not a safe recommendation: one take or checkpoint followed by a blocked holder can otherwise suppress the escape indefinitely. The progress evidence must expire.

One test issue from reading the pasted harness: its first case changes vc_last_poll backwards after 400 waits. A real last-checkpoint timestamp stays fixed when progress stops; it does not become older. Keeping the stamp fixed and asserting a bounded later escape would distinguish the static rule from the rolling deadline. Also assert that any scheduled harness event actually occurred: escapes_at_stamp starts at zero, so an escape before that event can leave the “no escape at the stamp” check passing. This is code review, not a claim that I ran the supplied test.

Your current-main / +#2387 / +rule matrix at 250 ms and the 2 s default is still untested here. These results cover only the older local experiment, through roughly 20 s after Start; no Windows run.

@CIB thanks for the baseline and the hunk. Your `8c92fe3` already contains `9f42700e`, so that old WaitAny scan does not explain your `nn.npln` report. I have read your posted hunk/test, but have **not run your patch** yet. Here is the completed first batch: **one run per arm**, with a disturbed run explicitly retained. Later batches are not included. Arch, RTX 4090, NVIDIA 615.78.08, headless, fresh profile, Start at 95 s, 115 s per run, escape thresholds set to 250 ms. These use **our local reading of your original static rule**, not your posted code, rolling deadline or probe kick. The baseline is `880cf67f` plus diagnostics/rule (`76326cba`); the other build adds #2301/#2302/#2322 (`fb2b393e`). Those three fixes have since merged, but these are not fresh builds of current main or of #2387. | Build / rule | Escapes | Escapes later than 255 ms | Waiters kept at deadline | Later granted a core | Longest wait for a core | |---|---:|---:|---:|---:|---:| | baseline, off | 6 | 0 | 0 | 0 | 250.9 ms | | baseline, static progress rule | 2 | 2 | 3 | 1 | 736.3 ms | | three fixes, off | 6 | 0 | 0 | 0 | 250.9 ms | | three fixes, static progress rule — disturbed | 8 | 8 | 10 | 5 | 671.4 ms | The numbers are asserted against the saved logs/table, including the individual escape identities: - With the rule off, 5 of 6 baseline escapes and all 6 with the three fixes had an allowed holder that had checkpointed since the wait began. With the static rule on, no escape in that category remained in either run. This small sample is not a general performance claim. - The baseline rule-on escapes were at 569.4 and 736.3 ms. `nn.npln.Callback`/`Worker` had each only just taken its core (0.0 ms held), with a checkpoint roughly 100 s old; the host samples were in `wait_any`. This demonstrates a stale-stamp case here, not the cause of your guest-mutex case. - **`OpusDecoder` now appears in our data.** In the disturbed rule-on run with the three fixes, it was the holder at one escape; one decode took 433.6 ms, and it and the Presentation Thread were sampled in uninterruptible sleep (D). Load rose from 10.56 to 84.80; the cause is not established. Three escapes occurred behind holders that had kept the core throughout the wait without a checkpoint. I have added this qualification to the earlier “never” statement; it was true of the earlier snapshot, not of all subsequent runs. On the proposed rule: **agreed that acquisition must extend a rolling deadline, not satisfy a permanent `last_take >= t0` condition.** My earlier take-counted variant is an experiment, not a safe recommendation: one take or checkpoint followed by a blocked holder can otherwise suppress the escape indefinitely. The progress evidence must expire. One test issue from reading the pasted harness: its first case changes `vc_last_poll` backwards after 400 waits. A real last-checkpoint timestamp stays fixed when progress stops; it does not become older. Keeping the stamp fixed and asserting a bounded later escape would distinguish the static rule from the rolling deadline. Also assert that any scheduled harness event actually occurred: `escapes_at_stamp` starts at zero, so an escape before that event can leave the “no escape at the stamp” check passing. This is code review, not a claim that I ran the supplied test. Your current-main / +#2387 / +rule matrix at 250 ms and the 2 s default is still untested here. These results cover only the older local experiment, through roughly 20 s after Start; no Windows run.
max855k commented 2026-11-11 18:32:00 +00:00 (Migrated from github.com)

Additional v2.1.0 Linux reproduction — virtual-core escapes followed by multiple worker crashes

I have reproduced a crash that appears closely related to the virtual-core scheduler issue described here.

System

  • Hookshot v2.1.0, official Linux AppImage build
  • Ubuntu 26.04.1 LTS
  • AMD Ryzen 5 5600X (6C/12T)
  • NVIDIA RTX 5080 (16 GB)
  • 32 GB DDR4-3600

Configuration

HOS_RESERVE_CORE=0
HOS_CORES=4

With the default six virtual cores, the game previously ran through Kokiri Forest without this particular crash.

After setting HOS_CORES=4, the game crashed during startup/scene loading.

Virtual-core escape messages immediately before the crash:

[hos PreActorModelCompose] vcores: PreActorModelCompose (priority 26) ran without a core after 250.4 ms: core 0: sead::MainThread (16);

[hos aglTextureDataMgr::Compaction] vcores: aglTextureDataMgr::Compaction (priority 25) ran without a core after 250.1 ms: core 1: ModuleSystemWorker1 (16);

[hos PreActorModelCompose] vcores: PreActorModelCompose (priority 26) ran without a core after 250.9 ms: core 3: ModuleSystemWorker3 (16);

[hos AudioFrameworkThread] vcores: 157503 takes, 112739 waits (mean 100 us, longest 250.9 ms), 20396 kicks, 74549 yields, 3 escapes in 5 s

Subsequent crashes:

[hos sead::MainThread] CRASH: exception 0xc0000005 (read 0x159c00000004, guest 0x59c00000004) at hookshot+0x2354b62

[hos ModuleSystemWorker5] CRASH: exception 0xc0000005 (read 0, guest 0xfffff00000000000) at hookshot+0x2357bd1

[hos ModuleSystemWorker2] CRASH: exception 0xc0000005 (read 0, guest 0xfffff00000000000) at hookshot+0x235317a

[hos ModuleSystemWorker3] CRASH: exception 0xc0000005 (read 0x159c00000004, guest 0x59c00000004) at hookshot+0x23502b1

Multiple virtual-core escapes occurred before the crashes, including an escaped thread while a ModuleSystemWorker held the core.

This strongly resembles the scheduler escape issue investigated here. I cannot independently confirm that the cached-core TLS state was corrupted, but the timing and failure pattern are consistent with the report.

I have preserved the complete crash log, including host and guest backtraces, and can provide it if helpful.

Additional note: With the default HOS_CORES=6, performance in demanding Kokiri Forest views is approximately 47–50 FPS and appears limited by CPU-side Vulkan replay. This separate performance issue is being tracked in #995.

### Additional v2.1.0 Linux reproduction — virtual-core escapes followed by multiple worker crashes I have reproduced a crash that appears closely related to the virtual-core scheduler issue described here. **System** - Hookshot v2.1.0, official Linux AppImage build - Ubuntu 26.04.1 LTS - AMD Ryzen 5 5600X (6C/12T) - NVIDIA RTX 5080 (16 GB) - 32 GB DDR4-3600 **Configuration** ```ini HOS_RESERVE_CORE=0 HOS_CORES=4 ``` With the default six virtual cores, the game previously ran through Kokiri Forest without this particular crash. After setting `HOS_CORES=4`, the game crashed during startup/scene loading. **Virtual-core escape messages immediately before the crash:** ```text [hos PreActorModelCompose] vcores: PreActorModelCompose (priority 26) ran without a core after 250.4 ms: core 0: sead::MainThread (16); [hos aglTextureDataMgr::Compaction] vcores: aglTextureDataMgr::Compaction (priority 25) ran without a core after 250.1 ms: core 1: ModuleSystemWorker1 (16); [hos PreActorModelCompose] vcores: PreActorModelCompose (priority 26) ran without a core after 250.9 ms: core 3: ModuleSystemWorker3 (16); [hos AudioFrameworkThread] vcores: 157503 takes, 112739 waits (mean 100 us, longest 250.9 ms), 20396 kicks, 74549 yields, 3 escapes in 5 s ``` **Subsequent crashes:** ```text [hos sead::MainThread] CRASH: exception 0xc0000005 (read 0x159c00000004, guest 0x59c00000004) at hookshot+0x2354b62 [hos ModuleSystemWorker5] CRASH: exception 0xc0000005 (read 0, guest 0xfffff00000000000) at hookshot+0x2357bd1 [hos ModuleSystemWorker2] CRASH: exception 0xc0000005 (read 0, guest 0xfffff00000000000) at hookshot+0x235317a [hos ModuleSystemWorker3] CRASH: exception 0xc0000005 (read 0x159c00000004, guest 0x59c00000004) at hookshot+0x23502b1 ``` Multiple virtual-core escapes occurred before the crashes, including an escaped thread while a ModuleSystemWorker held the core. This strongly resembles the scheduler escape issue investigated here. I cannot independently confirm that the cached-core TLS state was corrupted, but the timing and failure pattern are consistent with the report. I have preserved the complete crash log, including host and guest backtraces, and can provide it if helpful. **Additional note:** With the default `HOS_CORES=6`, performance in demanding Kokiri Forest views is approximately 47–50 FPS and appears limited by CPU-side Vulkan replay. This separate performance issue is being tracked in #995.
mooki000 commented 2026-11-11 18:35:15 +00:00 (Migrated from github.com)

Updated 2026-11-11: all three batches are complete. This edit consolidates all 12 runs; the original version of this comment covered only the second batch. The earlier frozen snapshots are retained.

Same older builds and settings: baseline 880cf67f; e = baseline plus diagnostics/rule (76326cba), f = plus #2301/#2302/#2322 (fb2b393e). Arch, RTX 4090, NVIDIA 615.78.08; 115 s headless runs, Start at 95 s, 250 ms escape thresholds. Rule 0 is off, 1 is our reading of the static checkpoint rule, and 2 also counts a core acquisition with a static since-wait-start test. This is not the supplied patch, a rolling deadline, or a probe kick.

Run Escapes Later than 255 ms Kept at deadline Later granted Longest grant wait (ms) Longest core wait (ms) Load at start
e0-01 6 0 0 0 0.0 250.9 88.59
e0-02 3 0 0 0 0.0 251.0 5.27
e1-01 2 2 3 1 501.4 736.3 23.91
e1-02 0 0 4 4 1447.6 1447.6 3.96
e2-01 0 0 8 8 1218.7 1218.7 5.06
e2-02 0 0 10 10 489.5 489.5 4.42
f0-01 6 0 0 0 0.0 250.9 13.33
f0-02 2 0 0 0 0.0 251.0 4.58
f1-01 8 8 10 5 671.4 671.4 10.56
f1-02 13 12 15 3 483.1 548.8 6.92
f2-01 0 0 7 7 1345.9 1345.9 14.74
f2-02 0 0 9 9 1114.3 1114.3 5.77

All table values and the new escape identities pass assertions against the frozen final snapshot. The new third-batch runs are e0-02, e2-02, f0-02, and f2-02.

  • The five new rule-off escapes all reported an allowed holder with a checkpoint stamp since the wait began. Two holders had kept the core throughout the wait (sead::MainThread, held 317.2 and 344.6 ms, checkpoint age 0.9 ms); the other three had taken their core during the wait. These are diagnostic snapshots, not proof of useful guest progress.
  • All 13 escapes in f1-02 named newly granted holders with stale checkpoint stamps: 12 nn.npln.*, one tera::FileResourceMgr, each reporting 0.0 ms held after rounding. None of those holder samples was in state D. The disturbed f1-01 remains in the table: it included the previously reported 433.6 ms Opus decode and D-state samples.
  • All four static take-counted runs had no escapes. That does not establish that the deadlock breaker is preserved. One take or checkpoint followed by a blocked holder can satisfy the permanent condition indefinitely; evidence of progress must expire. These game runs did not test that stalled-holder case.
  • Counts vary, and some grants take over a second. No frame-rate or general improvement claim follows from this sample, and it does not establish the cause of CIB’s guest-mutex report.

No current-main/+2387 matrix, rolling/probe implementation, or Windows test is represented here. No additional game runs were made to assemble this table.

@max855k please attach the complete hookshot_log.txt, including its first version/build line and the backtraces (personal paths can be redacted; no saves, game files or core dump). The escapes preceding the faults are useful evidence, but do not by themselves establish cached-core/TLS corruption. Keeping the working six-core configuration is sensible while this is investigated; the separate performance report can stay in #995.

**Updated 2026-11-11: all three batches are complete.** This edit consolidates all 12 runs; the original version of this comment covered only the second batch. The earlier frozen snapshots are retained. Same older builds and settings: baseline `880cf67f`; `e` = baseline plus diagnostics/rule (`76326cba`), `f` = plus #2301/#2302/#2322 (`fb2b393e`). Arch, RTX 4090, NVIDIA 615.78.08; 115 s headless runs, Start at 95 s, 250 ms escape thresholds. Rule `0` is off, `1` is our reading of the static checkpoint rule, and `2` also counts a core acquisition with a static since-wait-start test. **This is not the supplied patch, a rolling deadline, or a probe kick.** | Run | Escapes | Later than 255 ms | Kept at deadline | Later granted | Longest grant wait (ms) | Longest core wait (ms) | Load at start | |---|---:|---:|---:|---:|---:|---:|---:| | e0-01 | 6 | 0 | 0 | 0 | 0.0 | 250.9 | 88.59 | | e0-02 | 3 | 0 | 0 | 0 | 0.0 | 251.0 | 5.27 | | e1-01 | 2 | 2 | 3 | 1 | 501.4 | 736.3 | 23.91 | | e1-02 | 0 | 0 | 4 | 4 | 1447.6 | 1447.6 | 3.96 | | e2-01 | 0 | 0 | 8 | 8 | 1218.7 | 1218.7 | 5.06 | | e2-02 | 0 | 0 | 10 | 10 | 489.5 | 489.5 | 4.42 | | f0-01 | 6 | 0 | 0 | 0 | 0.0 | 250.9 | 13.33 | | f0-02 | 2 | 0 | 0 | 0 | 0.0 | 251.0 | 4.58 | | f1-01 | 8 | 8 | 10 | 5 | 671.4 | 671.4 | 10.56 | | f1-02 | 13 | 12 | 15 | 3 | 483.1 | 548.8 | 6.92 | | f2-01 | 0 | 0 | 7 | 7 | 1345.9 | 1345.9 | 14.74 | | f2-02 | 0 | 0 | 9 | 9 | 1114.3 | 1114.3 | 5.77 | All table values and the new escape identities pass assertions against the frozen final snapshot. The new third-batch runs are `e0-02`, `e2-02`, `f0-02`, and `f2-02`. - The five new rule-off escapes all reported an allowed holder with a checkpoint stamp since the wait began. Two holders had kept the core throughout the wait (`sead::MainThread`, held 317.2 and 344.6 ms, checkpoint age 0.9 ms); the other three had taken their core during the wait. These are diagnostic snapshots, not proof of useful guest progress. - All 13 escapes in `f1-02` named newly granted holders with stale checkpoint stamps: 12 `nn.npln.*`, one `tera::FileResourceMgr`, each reporting 0.0 ms held after rounding. None of those holder samples was in state D. The disturbed `f1-01` remains in the table: it included the previously reported 433.6 ms Opus decode and D-state samples. - All four static take-counted runs had no escapes. **That does not establish that the deadlock breaker is preserved.** One take or checkpoint followed by a blocked holder can satisfy the permanent condition indefinitely; evidence of progress must expire. These game runs did not test that stalled-holder case. - Counts vary, and some grants take over a second. No frame-rate or general improvement claim follows from this sample, and it does not establish the cause of CIB’s guest-mutex report. No current-main/+2387 matrix, rolling/probe implementation, or Windows test is represented here. No additional game runs were made to assemble this table. @max855k please attach the complete `hookshot_log.txt`, including its first version/build line and the backtraces (personal paths can be redacted; no saves, game files or core dump). The escapes preceding the faults are useful evidence, but do not by themselves establish cached-core/TLS corruption. Keeping the working six-core configuration is sensible while this is investigated; the separate performance report can stay in #995.
CIB commented 2026-11-11 22:10:12 +00:00 (Migrated from github.com)

Rolling deadline + probe, corrected test, and the 250 ms / 2 s matrix on 940eb9ef

@mooki000 — you were right on both test points and on the static stamp. Here is what changed, the corrected test, and the matrix. The matrix ran on 940eb9ef + the diff below (a dirty build, source 287f6032); I am giving the exact head rather than claiming current main.

The rule. I replaced the static vc_last_poll >= t0 with a rolling window and added the probe kick you preferred:

  • vc_last_poll (last checkpoint) and vc_last_take (last core acquisition) are stamped in hos_vcore_checkpoint and vc_take.
  • On each 1 ms look, a waiter sends vc_kick = 3 to better holders on its allowed cores. The holder reaches a checkpoint, records it, and clears the probe without yielding. It is not a yield request.
  • last_progress = max(t0, holders on the waiter's allowed cores: last_poll, last_take); the escape fires only when now - last_progress > timeout. A take resets the window; a holder that stops making progress trips the deadlock breaker one window later. HOS_VCORE_PROGRESS=0 keeps the old unconditional path for the control arm.
diff --git a/host/hos_thread.c b/host/hos_thread.c
index 9c9d638..a9d9d65 100644
--- a/host/hos_thread.c
+++ b/host/hos_thread.c
@@ -39,8 +39,10 @@ struct HosThread {
        whether it has run without one since an escape (vc_acquire; until its next block, hos_vcore_away, or the next
        hos_vcore_back) */
     int vc_held, vc_last, vc_queued, vc_escaped;
-    volatile int vc_kick;
+    volatile int vc_kick;   /* 1/2: give the core up (worse/equal); 3: probe (checkpoint, do not yield) */
     int64_t vc_since;
+    int64_t vc_last_poll;   /* (progress) the last time this thread reached a checkpoint */
+    int64_t vc_last_take;   /* (progress) the last time this thread took a core */
     CONDITION_VARIABLE vc_cv;
     /* (hookshot) fibers: the context running now (NULL: ctx, the thread's own), the thread's host fiber once it
        switched to a guest fiber */
@@ -1370,6 +1372,7 @@ static void vc_take(HosThread *t, int c) {
     t->vc_held = c + 1;
     t->vc_last = c + 1;
     t->vc_kick = 0;
+    t->vc_last_take = hos_now_ns();
 }
 static void vc_dequeue(HosThread *t) {
     for (int i = 0; i < vc_nq; i++)
@@ -1405,10 +1408,12 @@ static int64_t vc_env_ns(const char *name, int64_t us) {
 }
 static void vc_acquire(HosThread *t) {
     static int64_t esc_better = -1, esc_eq, slice;
+    static int progress = -1;   /* HOS_VCORE_PROGRESS=0: the old unconditional escape (the control arm) */
     if (esc_better < 0) {
         esc_eq = vc_env_ns("HOS_VCORE_ESCAPE_EQ_US", 2000000);
         esc_better = vc_env_ns("HOS_VCORE_ESCAPE_US", 2000000);
         slice = vc_env_ns("HOS_VCORE_SLICE_US", 2000);
+        progress = getenv("HOS_VCORE_PROGRESS") ? atoi(getenv("HOS_VCORE_PROGRESS")) != 0 : 1;
     }
     AcquireSRWLockExclusive(&vc_lock);
     vc_st.takes++;
@@ -1443,6 +1448,18 @@ static void vc_acquire(HosThread *t) {
             __atomic_store_n(rc ? &rc->poll : &worst->ctx.poll, 1, __ATOMIC_RELAXED);
             vc_st.kicks++;
         }
+        /* (progress) a better holder that nobody has asked reaches no checkpoint in a guest loop, so its stamp
+           alone cannot show progress: ask it for one, without a yield (vc_kick 3) */
+        if (progress)
+            for (int k = 0; k < vc_cores(); k++) {
+                HosThread *h = vc_owner[k];
+                if ((m >> k & 1) && h && h->priority < t->priority && !h->vc_kick) {
+                    h->vc_kick = 3;
+                    Ctx *rc = h->run_ctx;
+                    __atomic_store_n(rc ? &rc->poll : &h->ctx.poll, 1, __ATOMIC_RELAXED);
+                    vc_st.kicks++;
+                }
+            }
         SleepConditionVariableSRW(&t->vc_cv, &vc_lock, 1, 0);
         if (t->vc_held) break;   /* granted by a release */
         if ((c = vc_free_for(t)) >= 0) {
@@ -1450,21 +1467,36 @@ static void vc_acquire(HosThread *t) {
             vc_take(t, c);
             break;
         }
+        /* (progress) a rolling window: a checkpoint or a take after the wait began keeps it open only while it is
+           recent; a holder that stops making progress trips the deadlock breaker a full window later */
+        int64_t last_progress = t0;
+        for (int k = 0; k < vc_cores(); k++) {
+            HosThread *h = vc_owner[k];
+            if (!(m >> k & 1) || !h) continue;
+            if (h->vc_last_poll > last_progress) last_progress = h->vc_last_poll;
+            if (h->vc_last_take > last_progress) last_progress = h->vc_last_take;
+        }
         const int64_t waited = hos_now_ns() - t0;
-        if (waited > (worst ? esc_better : esc_eq)) {
+        if ((progress ? hos_now_ns() - last_progress : waited) > (worst ? esc_better : esc_eq)) {
             vc_dequeue(t);
             t->vc_escaped = 1;
             vc_st.escapes++;
             static int logged;
             if (logged < 40) {
                 logged++;
-                char who[256] = "";
+                char who[320] = "";
                 int len = 0;
-                for (int k = 0; k < vc_cores() && len < 200; k++)
-                    if (m >> k & 1)
-                        len += snprintf(who + len, sizeof who - len, " core %d: %s (%d)%s;", k,
-                                        vc_owner[k] ? vc_owner[k]->name : "-", vc_owner[k] ? vc_owner[k]->priority : 0,
-                                        vc_owner[k] && vc_owner[k]->vc_kick ? " asked" : "");
+                const int64_t now = hos_now_ns();
+                for (int k = 0; k < vc_cores() && len < 260; k++)
+                    if (m >> k & 1) {
+                        HosThread *h = vc_owner[k];
+                        len += snprintf(who + len, sizeof who - len,
+                                        " core %d: %s (%d) held %.1f ms, stamp %.1f ms%s;", k,
+                                        h ? h->name : "-", h ? h->priority : 0,
+                                        h ? (now - h->vc_last_take) / 1e6 : 0.0,
+                                        h ? (now - h->vc_last_poll) / 1e6 : 0.0,
+                                        h && h->vc_kick ? " asked" : "");
+                    }
                 hos_log("vcores: %s (priority %d) ran without a core after %.1f ms:%s", t->name, t->priority, waited / 1e6, who);
             }
             break;
@@ -1533,7 +1565,11 @@ void hos_vcore_back(void) {
    stall dump of a thread spinning in a polled loop shows such older registers for the same reason. */
 void hos_vcore_checkpoint(void) {
     HosThread *t = cur;
-    if (t && t->vc_kick) vc_yield(t, t->vc_kick == 1);
+    if (t) __atomic_store_n(&t->vc_last_poll, hos_now_ns(), __ATOMIC_RELAXED);
+    if (t && t->vc_kick) {
+        if (t->vc_kick == 3) t->vc_kick = 0;   /* (progress) a probe: recorded, no yield */
+        else vc_yield(t, t->vc_kick == 1);
+    }
 }
 void hos_vcore_yield(void) {
     HosThread *t = cur;

The held/stamp fields in the escape lines are the diagnostics added with this diff (the log hunk is in the diff above).

The test. Both review points are fixed:

  • No backwards stamp. Case 1 leaves a fresh stamp fixed and asserts that the escape happens in a bounded window after it ages. The static rule never escapes there, and the harness watchdog fails it at 3 s.
  • The probe event is asserted. The harness counts probe responses (probes_answered >= 3), checks escapes_when_probes_stop == 0, and the watchdog fails if the awaited escape never comes.

It passes with g++ and clang++; a copy of hos_thread.c with the static rule fails at the 3 s watchdog. I can paste the full tests/test_vcore_progress.py.

Matrix on 940eb9ef + the diff (it already contains #2301/#2302/#2322 and #2387). Four runs per arm, anonymous window, 0 crashes in all 12, fps 59.3–60.8. These are the harness's ~230 s runs (boot/title plus 45 s after Start); the escape counts are over the whole run, not only the 45 s.

arm escapes holders
250 ms, rule on 7 all BootupBackgroundWorkThread (30), a worse holder in host code
250 ms, rule off 98 BaseProcCreateAndDeleteThread 30, sead::MainThread 21, ParallelDecompThread 19, EffectResLoadingThread 11, BootupBackgroundWorkThread 7, ARes Loading 1 3, ムービー入力読込 3, ModuleSystemWorker4 2, tera::FileResourceMgr 1, Resource Memory 1
2 s, rule on 0 —
  • Rule on: every escape was behind BootupBackgroundWorkThread (30), a worse holder in host code, held 252.8–315.6 ms with a 250.4–312.4 ms old stamp. No better-holder escape in this arm.
  • Rule off: ParallelDecompThread (19) held 0.3–4.8 ms with a 0.3–77.6 ms stamp; BaseProcCreateAndDeleteThread (23) held 0.0–13.7 ms.
  • sead::MainThread (16) is 21 escapes; 16 of them are the long holds (301.6–1381.3 ms, stamp 0.0–3.5 ms), the other five held 0.0–0.1 ms. The ones with a 0–3.5 ms stamp were still making progress when the rule-off build escaped them — the harmful case the rule is for. The static-vs-rolling proof is test case 1 (a fixed fresh stamp), not this sample.
  • The probe path itself is covered by the host test: no rule-on escape in this game sample had a better holder.

Not measured: Windows, the probe path in a game run, and the kernel log (the journal has no entries during the matrix window on this boot, so I am not claiming a clean kernel check).

Does this answer the take/static concern? The window length and probe rate are the knobs I would expect to tune.


🤖 Generated with opencode using DeepSeek V4.1 Flash.

### Rolling deadline + probe, corrected test, and the 250 ms / 2 s matrix on 940eb9ef @mooki000 — you were right on both test points and on the static stamp. Here is what changed, the corrected test, and the matrix. The matrix ran on **`940eb9ef` + the diff below** (a dirty build, `source 287f6032`); I am giving the exact head rather than claiming current main. **The rule.** I replaced the static `vc_last_poll >= t0` with a rolling window and added the probe kick you preferred: - `vc_last_poll` (last checkpoint) and `vc_last_take` (last core acquisition) are stamped in `hos_vcore_checkpoint` and `vc_take`. - On each 1 ms look, a waiter sends `vc_kick = 3` to better holders on its allowed cores. The holder reaches a checkpoint, records it, and clears the probe without yielding. It is not a yield request. - `last_progress = max(t0, holders on the waiter's allowed cores: last_poll, last_take)`; the escape fires only when `now - last_progress > timeout`. A take resets the window; a holder that stops making progress trips the deadlock breaker one window later. `HOS_VCORE_PROGRESS=0` keeps the old unconditional path for the control arm. ```diff diff --git a/host/hos_thread.c b/host/hos_thread.c index 9c9d638..a9d9d65 100644 --- a/host/hos_thread.c +++ b/host/hos_thread.c @@ -39,8 +39,10 @@ struct HosThread { whether it has run without one since an escape (vc_acquire; until its next block, hos_vcore_away, or the next hos_vcore_back) */ int vc_held, vc_last, vc_queued, vc_escaped; - volatile int vc_kick; + volatile int vc_kick; /* 1/2: give the core up (worse/equal); 3: probe (checkpoint, do not yield) */ int64_t vc_since; + int64_t vc_last_poll; /* (progress) the last time this thread reached a checkpoint */ + int64_t vc_last_take; /* (progress) the last time this thread took a core */ CONDITION_VARIABLE vc_cv; /* (hookshot) fibers: the context running now (NULL: ctx, the thread's own), the thread's host fiber once it switched to a guest fiber */ @@ -1370,6 +1372,7 @@ static void vc_take(HosThread *t, int c) { t->vc_held = c + 1; t->vc_last = c + 1; t->vc_kick = 0; + t->vc_last_take = hos_now_ns(); } static void vc_dequeue(HosThread *t) { for (int i = 0; i < vc_nq; i++) @@ -1405,10 +1408,12 @@ static int64_t vc_env_ns(const char *name, int64_t us) { } static void vc_acquire(HosThread *t) { static int64_t esc_better = -1, esc_eq, slice; + static int progress = -1; /* HOS_VCORE_PROGRESS=0: the old unconditional escape (the control arm) */ if (esc_better < 0) { esc_eq = vc_env_ns("HOS_VCORE_ESCAPE_EQ_US", 2000000); esc_better = vc_env_ns("HOS_VCORE_ESCAPE_US", 2000000); slice = vc_env_ns("HOS_VCORE_SLICE_US", 2000); + progress = getenv("HOS_VCORE_PROGRESS") ? atoi(getenv("HOS_VCORE_PROGRESS")) != 0 : 1; } AcquireSRWLockExclusive(&vc_lock); vc_st.takes++; @@ -1443,6 +1448,18 @@ static void vc_acquire(HosThread *t) { __atomic_store_n(rc ? &rc->poll : &worst->ctx.poll, 1, __ATOMIC_RELAXED); vc_st.kicks++; } + /* (progress) a better holder that nobody has asked reaches no checkpoint in a guest loop, so its stamp + alone cannot show progress: ask it for one, without a yield (vc_kick 3) */ + if (progress) + for (int k = 0; k < vc_cores(); k++) { + HosThread *h = vc_owner[k]; + if ((m >> k & 1) && h && h->priority < t->priority && !h->vc_kick) { + h->vc_kick = 3; + Ctx *rc = h->run_ctx; + __atomic_store_n(rc ? &rc->poll : &h->ctx.poll, 1, __ATOMIC_RELAXED); + vc_st.kicks++; + } + } SleepConditionVariableSRW(&t->vc_cv, &vc_lock, 1, 0); if (t->vc_held) break; /* granted by a release */ if ((c = vc_free_for(t)) >= 0) { @@ -1450,21 +1467,36 @@ static void vc_acquire(HosThread *t) { vc_take(t, c); break; } + /* (progress) a rolling window: a checkpoint or a take after the wait began keeps it open only while it is + recent; a holder that stops making progress trips the deadlock breaker a full window later */ + int64_t last_progress = t0; + for (int k = 0; k < vc_cores(); k++) { + HosThread *h = vc_owner[k]; + if (!(m >> k & 1) || !h) continue; + if (h->vc_last_poll > last_progress) last_progress = h->vc_last_poll; + if (h->vc_last_take > last_progress) last_progress = h->vc_last_take; + } const int64_t waited = hos_now_ns() - t0; - if (waited > (worst ? esc_better : esc_eq)) { + if ((progress ? hos_now_ns() - last_progress : waited) > (worst ? esc_better : esc_eq)) { vc_dequeue(t); t->vc_escaped = 1; vc_st.escapes++; static int logged; if (logged < 40) { logged++; - char who[256] = ""; + char who[320] = ""; int len = 0; - for (int k = 0; k < vc_cores() && len < 200; k++) - if (m >> k & 1) - len += snprintf(who + len, sizeof who - len, " core %d: %s (%d)%s;", k, - vc_owner[k] ? vc_owner[k]->name : "-", vc_owner[k] ? vc_owner[k]->priority : 0, - vc_owner[k] && vc_owner[k]->vc_kick ? " asked" : ""); + const int64_t now = hos_now_ns(); + for (int k = 0; k < vc_cores() && len < 260; k++) + if (m >> k & 1) { + HosThread *h = vc_owner[k]; + len += snprintf(who + len, sizeof who - len, + " core %d: %s (%d) held %.1f ms, stamp %.1f ms%s;", k, + h ? h->name : "-", h ? h->priority : 0, + h ? (now - h->vc_last_take) / 1e6 : 0.0, + h ? (now - h->vc_last_poll) / 1e6 : 0.0, + h && h->vc_kick ? " asked" : ""); + } hos_log("vcores: %s (priority %d) ran without a core after %.1f ms:%s", t->name, t->priority, waited / 1e6, who); } break; @@ -1533,7 +1565,11 @@ void hos_vcore_back(void) { stall dump of a thread spinning in a polled loop shows such older registers for the same reason. */ void hos_vcore_checkpoint(void) { HosThread *t = cur; - if (t && t->vc_kick) vc_yield(t, t->vc_kick == 1); + if (t) __atomic_store_n(&t->vc_last_poll, hos_now_ns(), __ATOMIC_RELAXED); + if (t && t->vc_kick) { + if (t->vc_kick == 3) t->vc_kick = 0; /* (progress) a probe: recorded, no yield */ + else vc_yield(t, t->vc_kick == 1); + } } void hos_vcore_yield(void) { HosThread *t = cur; ``` The `held`/`stamp` fields in the escape lines are the diagnostics added with this diff (the log hunk is in the diff above). **The test.** Both review points are fixed: - No backwards stamp. Case 1 leaves a fresh stamp fixed and asserts that the escape happens in a bounded window after it ages. The static rule never escapes there, and the harness watchdog fails it at 3 s. - The probe event is asserted. The harness counts probe responses (`probes_answered >= 3`), checks `escapes_when_probes_stop == 0`, and the watchdog fails if the awaited escape never comes. It passes with g++ and clang++; a copy of `hos_thread.c` with the static rule fails at the 3 s watchdog. I can paste the full `tests/test_vcore_progress.py`. **Matrix on `940eb9ef` + the diff** (it already contains #2301/#2302/#2322 and #2387). Four runs per arm, anonymous window, 0 crashes in all 12, fps 59.3–60.8. These are the harness's ~230 s runs (boot/title plus 45 s after Start); the escape counts are over the whole run, not only the 45 s. | arm | escapes | holders | |---|---:|---| | 250 ms, rule on | **7** | all `BootupBackgroundWorkThread` (30), a worse holder in host code | | 250 ms, rule off | **98** | `BaseProcCreateAndDeleteThread` 30, `sead::MainThread` 21, `ParallelDecompThread` 19, `EffectResLoadingThread` 11, `BootupBackgroundWorkThread` 7, `ARes Loading 1` 3, `ムービー入力読込` 3, `ModuleSystemWorker4` 2, `tera::FileResourceMgr` 1, `Resource Memory` 1 | | 2 s, rule on | **0** | — | - Rule on: every escape was behind `BootupBackgroundWorkThread` (30), a worse holder in host code, held 252.8–315.6 ms with a 250.4–312.4 ms old stamp. No better-holder escape in this arm. - Rule off: `ParallelDecompThread` (19) held 0.3–4.8 ms with a 0.3–77.6 ms stamp; `BaseProcCreateAndDeleteThread` (23) held 0.0–13.7 ms. - `sead::MainThread` (16) is 21 escapes; 16 of them are the long holds (301.6–1381.3 ms, stamp 0.0–3.5 ms), the other five held 0.0–0.1 ms. The ones with a 0–3.5 ms stamp were still making progress when the rule-off build escaped them — the harmful case the rule is for. The static-vs-rolling proof is test case 1 (a fixed fresh stamp), not this sample. - The probe path itself is covered by the host test: no rule-on escape in this game sample had a better holder. **Not measured:** Windows, the probe path in a game run, and the kernel log (the journal has no entries during the matrix window on this boot, so I am not claiming a clean kernel check). Does this answer the take/static concern? The window length and probe rate are the knobs I would expect to tune. --- 🤖 Generated with [opencode](https://opencode.ai) using DeepSeek V4.1 Flash.
mooki000 commented 2026-11-11 23:20:16 +00:00 (Migrated from github.com)

@CIB thanks. I applied your 22:10 diff unchanged to 940eb9ef. It applies cleanly and builds.

Your test. The corrected tests/test_vcore_progress.py isn't in the thread yet ("I can paste the full ..."), so I haven't run it, and I can't confirm the two fixes from the description alone (case 1 no longer rewinding its stamp, and escapes_at_stamp no longer starting at zero). Please paste it, and I'll run it with g++ and clang++, on your diff and against main's source. In the rule itself I found nothing that moves a stamp backwards: both stamps are written only from hos_now_ns().

Your rule, with our own game-free check. The virtual-core block is cut out of your hos_thread.c and run single-threaded with scripted holders, with the escape at 250 ms, on g++ and clang++. All times below are that harness's clock, not the game's.

  • The stalled holder is handled now. A holder whose checkpoints stop at 100 ms trips the escape at 350 ms. One that takes the core at 300 ms and then makes no checkpoint trips it at 551 ms. A holder that keeps making progress still keeps the waiter: one that checkpoints until it gives the core up at 400 ms hands it over at 400 ms.
  • One hole: the probe and the yield request share vc_kick. Take a holder H, a worse waiter A that probes it, and a waiter B that is better than H. While A's probe is pending, B can't set kick 1, and H's answer to the probe clears vc_kick without a yield. If A's look always comes just after H's checkpoint, B gets the core only when H blocks. In the test below that is 1000 ms with your diff, against 1 ms on main and 1 ms with the change below. The test fails on your diff and passes with the change, with g++ and clang++. It also passes on main, which has no probe. With the change, every other case of our check comes out the same as on your diff.
--- a/host/hos_thread.c
+++ b/host/hos_thread.c
@@ -1442,7 +1442,7 @@
         if (!worst && hos_now_ns() - t0 > slice)
             for (int k = 0; k < vc_cores() && !worst; k++)
                 if ((m >> k & 1) && vc_owner[k] && vc_owner[k]->priority == t->priority) worst = vc_owner[k];
-        if (worst && !worst->vc_kick) {
+        if (worst && (!worst->vc_kick || worst->vc_kick == 3)) {   /* (a yield request replaces a pending probe) */
             worst->vc_kick = worst->priority > t->priority ? 1 : 2;
             Ctx *rc = worst->run_ctx;   /* (a fiber's context while it runs one) */
             __atomic_store_n(rc ? &rc->poll : &worst->ctx.poll, 1, __ATOMIC_RELAXED);
@@ -1567,8 +1567,8 @@
     HosThread *t = cur;
     if (t) __atomic_store_n(&t->vc_last_poll, hos_now_ns(), __ATOMIC_RELAXED);
     if (t && t->vc_kick) {
-        if (t->vc_kick == 3) t->vc_kick = 0;   /* (progress) a probe: recorded, no yield */
-        else vc_yield(t, t->vc_kick == 1);
+        int probe = 3;   /* (progress) a probe: recorded, no yield; a yield request that replaced it meanwhile is kept */
+        if (!__atomic_compare_exchange_n(&t->vc_kick, &probe, 0, 0, __ATOMIC_RELAXED, __ATOMIC_RELAXED)) vc_yield(t, probe == 1);
     }
 }
 void hos_vcore_yield(void) {

The first hunk is the fix the test checks. The second hunk keeps the holder's clear of the probe from wiping a yield request that replaced the probe in the meantime. That race needs two threads, so the single-threaded test can't show it; I found it by reading the code. I can paste the test if you want it beside yours.

  • Something to be aware of: a better holder in a polled loop answers every probe, so the worse waiter behind it now waits as long as that loop runs (5 s in the harness, with no escape). That's the case your rule is for. It also means nothing bounds that wait any more: if a better thread ever spin-waits on the worse one, the escape won't break it.
  • Two small points. The new loop reads vc_last_poll with a plain load, where your 17:31 hunk used __atomic_load_n. And a probed holder now shows asked in the escape line, so "asked" there no longer means only a yield request.

The matrix. Our side of it (250 ms and 2 s, your rule on and off, two runs per arm) isn't run yet, and I'll add it when this machine is quiet. The build's shader cache started empty, and the preparation got through only 2543 of 38809 programs in 7 minutes while the machine's load was at 190 to 339. Escape counts taken at that load wouldn't compare with yours or with our earlier runs.

@CIB thanks. I applied your 22:10 diff unchanged to 940eb9ef. It applies cleanly and builds. **Your test.** The corrected `tests/test_vcore_progress.py` isn't in the thread yet ("I can paste the full ..."), so I haven't run it, and I can't confirm the two fixes from the description alone (case 1 no longer rewinding its stamp, and `escapes_at_stamp` no longer starting at zero). Please paste it, and I'll run it with g++ and clang++, on your diff and against main's source. In the rule itself I found nothing that moves a stamp backwards: both stamps are written only from `hos_now_ns()`. **Your rule, with our own game-free check.** The virtual-core block is cut out of your `hos_thread.c` and run single-threaded with scripted holders, with the escape at 250 ms, on g++ and clang++. All times below are that harness's clock, not the game's. - **The stalled holder is handled now.** A holder whose checkpoints stop at 100 ms trips the escape at 350 ms. One that takes the core at 300 ms and then makes no checkpoint trips it at 551 ms. A holder that keeps making progress still keeps the waiter: one that checkpoints until it gives the core up at 400 ms hands it over at 400 ms. - **One hole: the probe and the yield request share `vc_kick`.** Take a holder H, a worse waiter A that probes it, and a waiter B that is better than H. While A's probe is pending, B can't set kick 1, and H's answer to the probe clears `vc_kick` without a yield. If A's look always comes just after H's checkpoint, B gets the core only when H blocks. In the test below that is 1000 ms with your diff, against 1 ms on main and 1 ms with the change below. The test fails on your diff and passes with the change, with g++ and clang++. It also passes on main, which has no probe. With the change, every other case of our check comes out the same as on your diff. ```diff --- a/host/hos_thread.c +++ b/host/hos_thread.c @@ -1442,7 +1442,7 @@ if (!worst && hos_now_ns() - t0 > slice) for (int k = 0; k < vc_cores() && !worst; k++) if ((m >> k & 1) && vc_owner[k] && vc_owner[k]->priority == t->priority) worst = vc_owner[k]; - if (worst && !worst->vc_kick) { + if (worst && (!worst->vc_kick || worst->vc_kick == 3)) { /* (a yield request replaces a pending probe) */ worst->vc_kick = worst->priority > t->priority ? 1 : 2; Ctx *rc = worst->run_ctx; /* (a fiber's context while it runs one) */ __atomic_store_n(rc ? &rc->poll : &worst->ctx.poll, 1, __ATOMIC_RELAXED); @@ -1567,8 +1567,8 @@ HosThread *t = cur; if (t) __atomic_store_n(&t->vc_last_poll, hos_now_ns(), __ATOMIC_RELAXED); if (t && t->vc_kick) { - if (t->vc_kick == 3) t->vc_kick = 0; /* (progress) a probe: recorded, no yield */ - else vc_yield(t, t->vc_kick == 1); + int probe = 3; /* (progress) a probe: recorded, no yield; a yield request that replaced it meanwhile is kept */ + if (!__atomic_compare_exchange_n(&t->vc_kick, &probe, 0, 0, __ATOMIC_RELAXED, __ATOMIC_RELAXED)) vc_yield(t, probe == 1); } } void hos_vcore_yield(void) { ``` The first hunk is the fix the test checks. The second hunk keeps the holder's clear of the probe from wiping a yield request that replaced the probe in the meantime. That race needs two threads, so the single-threaded test can't show it; I found it by reading the code. I can paste the test if you want it beside yours. - **Something to be aware of:** a better holder in a polled loop answers every probe, so the worse waiter behind it now waits as long as that loop runs (5 s in the harness, with no escape). That's the case your rule is for. It also means nothing bounds that wait any more: if a better thread ever spin-waits on the worse one, the escape won't break it. - Two small points. The new loop reads `vc_last_poll` with a plain load, where your 17:31 hunk used `__atomic_load_n`. And a probed holder now shows ` asked` in the escape line, so "asked" there no longer means only a yield request. **The matrix.** Our side of it (250 ms and 2 s, your rule on and off, two runs per arm) isn't run yet, and I'll add it when this machine is quiet. The build's shader cache started empty, and the preparation got through only 2543 of 38809 programs in 7 minutes while the machine's load was at 190 to 339. Escape counts taken at that load wouldn't compare with yours or with our earlier runs.
CIB commented 2026-11-11 23:30:09 +00:00 (Migrated from github.com)

@mooki000 — the corrected tests/test_vcore_progress.py, as asked. Both review points are in it:

  • case 1 leaves a fresh stamp fixed and asserts a bounded escape after it ages (the static rule fails the 3 s watchdog);
  • the probe event is asserted: probes_answered >= 3, escapes_when_probes_stop == 0, and the watchdog fails if the awaited escape never comes.

It passes with g++ and clang++ on the diff I posted; a copy of hos_thread.c with the static rule fails at the 3 s watchdog. The fixture defines no-op HOS_CHAIN macros because current main's virtual-core block uses them.

"""A waiter for a virtual core does not escape while a holder on one of the waiter's allowed cores is
making progress; progress is a rolling window, not a one-off stamp (host/hos_thread.c vc_acquire;
issue 1929). The probe kick is part of the test: a better holder that nobody has asked reaches no
checkpoint in a guest loop, so the waiter asks it for one without a yield (vc_kick 3).

The actual HosThread, hos_thread_core and the whole virtual-core block (from vc_lock to hos_cv_sleep)
are compiled with a single-threaded harness: a clock the waits advance, a holder whose checkpoint the
harness simulates when the probe arrives, and a probe budget that can be stopped to show that the
deadline is rolling (a fixed stamp cannot keep the waiter waiting forever).

python -I -B tests/test_vcore_progress.py [--cxx clang++]
"""
import argparse
import os
from pathlib import Path
import shutil
import subprocess
import tempfile

RUN_ALL_ARGS = ["--cxx", "{cxx}"]

ROOT = Path(__file__).resolve().parents[1]


def block(source, marker):
    start = source.index(marker)
    end = source.index("{", start) + 1
    depth = 1
    while depth:
        depth += (source[end] == "{") - (source[end] == "}")
        end += 1
    return source[start:end] + (";" if source[end:end + 1] == ";" else "")


HARNESS = r"""
#include <stdarg.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
typedef void *HANDLE;
typedef int BOOL;
typedef unsigned long DWORD;
typedef struct { void *p; } SRWLOCK, *PSRWLOCK;
typedef struct { void *p; } CONDITION_VARIABLE, *PCONDITION_VARIABLE;
#define SRWLOCK_INIT {0}
#define HOS_TLS_SLOTS 64
typedef struct { uint64_t x[32]; volatile int poll; } Ctx;
static int64_t now_ns = 1000000000;
static int64_t hos_now_ns(void) { return now_ns; }
static int escapes_logged;
static void hos_log(const char *fmt, ...) { if (strstr(fmt, "ran without a core")) escapes_logged++; }
static int hos_cores(void) { return 2; }
enum { HOS_CHAIN_WAIT = 1, HOS_CHAIN_WOKE, HOS_CHAIN_SIGNAL, HOS_CHAIN_SLEEP, HOS_CHAIN_AWAKE, HOS_CHAIN_PRESENT };
#define HOS_CHAIN(kind, key) do { (void)(kind); (void)(key); } while (0)
@STRUCT@
void hos_vcore_checkpoint(void);
static HosThread *cur;
static HosThread *stamp_holder;
static int probe_answers_left;   /* -1: answer every probe; 0: none */
static int probes_answered;
static int escapes_when_probes_stop = -1;
static int sleeps;
static BOOL SleepConditionVariableSRW(PCONDITION_VARIABLE cv, PSRWLOCK lk, DWORD ms, unsigned long f) {
    (void)cv, (void)lk, (void)f;
    now_ns += (int64_t)(ms ? ms : 1) * 1000000;
    sleeps++;
    if (sleeps > 3000) { puts("FAIL: no escape after 3 s of waiting (a static rule?)"); exit(2); }
    if (stamp_holder && stamp_holder->vc_kick == 3 && probe_answers_left != 0) {
        HosThread *save = cur;
        cur = stamp_holder;
        hos_vcore_checkpoint();   /* records the stamp, clears the probe, does not yield */
        cur = save;
        probes_answered++;
        if (probe_answers_left > 0) probe_answers_left--;
        if (probe_answers_left == 0) escapes_when_probes_stop = escapes_logged;
    }
    return 1;
}
static void WakeConditionVariable(PCONDITION_VARIABLE cv) { (void)cv; }
static void AcquireSRWLockExclusive(PSRWLOCK l) { (void)l; }
static void ReleaseSRWLockExclusive(PSRWLOCK l) { (void)l; }
@CORE@
@VCORES@
static int bad;
static void check(int ok, const char *what) { if (!ok) { printf("FAIL: %s\n", what); bad = 1; } }
static void thread(HosThread *t, const char *name, int prio, uint64_t mask) {
    memset(t, 0, sizeof *t);
    snprintf(t->name, sizeof t->name, "%s", name);
    t->priority = prio, t->core_mask = mask, t->ideal_core = -2;
}
static void hold(HosThread *t) { cur = t; hos_vcore_back(); }
static void reset(void) { memset(vc_owner, 0, sizeof vc_owner); escapes_logged = 0; sleeps = 0; probes_answered = 0; escapes_when_probes_stop = -1; }
int main(void) {
    setenv("HOS_VCORE_ESCAPE_US", "250000", 1);
    setenv("HOS_VCORE_ESCAPE_EQ_US", "250000", 1);
    HosThread h0, h1, t;

    /* 1. a fixed fresh stamp cannot keep the waiter waiting forever: the window rolls */
    thread(&h0, "holder0", 16, 1), thread(&h1, "holder1", 16, 2), thread(&t, "waiter", 26, 3);
    reset(); hold(&h0), hold(&h1);
    h0.vc_last_poll = now_ns + 1000000;   /* fresh when the wait starts, then never again */
    h1.vc_last_poll = now_ns - 2000000000;
    stamp_holder = &h0, probe_answers_left = 0;
    cur = &t; hos_vcore_back();
    check(escapes_logged == 1 && !t.vc_held, "a fresh but fixed stamp still trips the escape (rolling deadline)");
    check(sleeps >= 250 && sleeps <= 900, "and within a bounded window after the stamp ages");

    /* 2. probes count as progress while they are answered, and stop the escape only while they are */
    thread(&h0, "holder0", 16, 1), thread(&h1, "holder1", 16, 2), thread(&t, "waiter", 26, 3);
    reset(); hold(&h0), hold(&h1);
    h0.vc_last_poll = now_ns - 2000000000, h1.vc_last_poll = now_ns - 2000000000;
    stamp_holder = &h0, probe_answers_left = 3;
    cur = &t; hos_vcore_back();
    check(probes_answered >= 3, "the better holder was probed, not asked to yield");
    check(escapes_when_probes_stop == 0, "no escape while the probes were answered");
    check(escapes_logged == 1 && !t.vc_held, "the escape comes once the holder stops answering");
    check(sleeps >= 250 && sleeps <= 900, "a full rolling window after the last answered probe");

    /* 3. an unanswering holder trips the deadline at ~250 ms */
    thread(&h0, "holder0", 16, 1), thread(&h1, "holder1", 16, 2), thread(&t, "waiter", 26, 3);
    reset(); hold(&h0), hold(&h1);
    h0.vc_last_poll = now_ns - 2000000000, h1.vc_last_poll = now_ns - 2000000000;
    stamp_holder = &h0, probe_answers_left = 0;
    cur = &t; hos_vcore_back();
    check(escapes_logged == 1 && !t.vc_held, "a waiter escapes behind a holder that has not checkpointed");
    check(sleeps >= 250 && sleeps <= 900, "only after its 250 ms deadline");

    /* 4. only holders of the cores the waiter may use count */
    thread(&h0, "holder0", 16, 1), thread(&h1, "holder1", 16, 2), thread(&t, "waiter", 26, 1);
    reset(); hold(&h0), hold(&h1);
    h0.vc_last_poll = now_ns - 2000000000;   /* the waiter's core: stale */
    h1.vc_last_poll = now_ns + 1000000;      /* not its core: fresh */
    stamp_holder = NULL, probe_answers_left = 0;
    cur = &t; hos_vcore_back();
    check(escapes_logged == 1 && !t.vc_held, "a fresh holder on a core the waiter may not use is not progress");

    /* 5. a checkpoint records the time; a probe checkpoint does not release the core */
    thread(&h0, "holder0", 16, 1);
    memset(vc_owner, 0, sizeof vc_owner);
    h0.vc_held = 1, vc_owner[0] = &h0, h0.vc_kick = 3, h0.vc_last_poll = 0;
    now_ns += 5000000;
    cur = &h0;
    hos_vcore_checkpoint();
    check(h0.vc_last_poll == now_ns, "a checkpoint records the time it was reached");
    check(h0.vc_kick == 0 && h0.vc_held == 1, "a probe checkpoint is cleared without giving the core up");

    if (!bad) puts("ok");
    return bad;
}
"""


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--cxx", default="clang++")
    parser.add_argument("--source", type=Path, default=ROOT / "host/hos_thread.c")
    args = parser.parse_args()
    src = args.source.read_text(encoding="utf-8")
    start = src.index("static SRWLOCK vc_lock = SRWLOCK_INIT;")
    end = src.index("\n#else", src.index("int hos_cv_sleep(void *cv, void *lk, unsigned ms) {", start))
    fixture = (HARNESS.replace("@STRUCT@", block(src, "struct HosThread {") + "\ntypedef struct HosThread HosThread;")
               .replace("@CORE@", block(src, "int hos_thread_core(HosThread *t) {"))
               .replace("@VCORES@", src[start:end]))
    compiler = shutil.which(args.cxx)
    if not compiler:
        raise SystemExit("C++ compiler not found: " + args.cxx)
    with tempfile.TemporaryDirectory(prefix="vcore-progress-") as temporary:
        folder = Path(temporary)
        (folder / "fixture.cpp").write_text(fixture, encoding="utf-8")
        exe = folder / ("fixture.exe" if os.name == "nt" else "fixture")
        subprocess.run([compiler, "-std=c++17", "-O1", "-w", str(folder / "fixture.cpp"), "-o", str(exe)], check=True,
                       timeout=120)
        result = subprocess.run([str(exe)], capture_output=True, text=True, timeout=30)
        print(result.stdout, end="")
        if result.returncode or "ok" not in result.stdout:
            raise SystemExit("a waiter escapes while a holder on one of its cores is making progress (issue 1929)")


if __name__ == "__main__":
    main()

🤖 Generated with opencode using DeepSeek V4.1 Flash.

@mooki000 — the corrected `tests/test_vcore_progress.py`, as asked. Both review points are in it: - case 1 leaves a fresh stamp fixed and asserts a bounded escape after it ages (the static rule fails the 3 s watchdog); - the probe event is asserted: `probes_answered >= 3`, `escapes_when_probes_stop == 0`, and the watchdog fails if the awaited escape never comes. It passes with g++ and clang++ on the diff I posted; a copy of `hos_thread.c` with the static rule fails at the 3 s watchdog. The fixture defines no-op `HOS_CHAIN` macros because current main's virtual-core block uses them. ```python """A waiter for a virtual core does not escape while a holder on one of the waiter's allowed cores is making progress; progress is a rolling window, not a one-off stamp (host/hos_thread.c vc_acquire; issue 1929). The probe kick is part of the test: a better holder that nobody has asked reaches no checkpoint in a guest loop, so the waiter asks it for one without a yield (vc_kick 3). The actual HosThread, hos_thread_core and the whole virtual-core block (from vc_lock to hos_cv_sleep) are compiled with a single-threaded harness: a clock the waits advance, a holder whose checkpoint the harness simulates when the probe arrives, and a probe budget that can be stopped to show that the deadline is rolling (a fixed stamp cannot keep the waiter waiting forever). python -I -B tests/test_vcore_progress.py [--cxx clang++] """ import argparse import os from pathlib import Path import shutil import subprocess import tempfile RUN_ALL_ARGS = ["--cxx", "{cxx}"] ROOT = Path(__file__).resolve().parents[1] def block(source, marker): start = source.index(marker) end = source.index("{", start) + 1 depth = 1 while depth: depth += (source[end] == "{") - (source[end] == "}") end += 1 return source[start:end] + (";" if source[end:end + 1] == ";" else "") HARNESS = r""" #include <stdarg.h> #include <stdint.h> #include <stdio.h> #include <stdlib.h> #include <string.h> typedef void *HANDLE; typedef int BOOL; typedef unsigned long DWORD; typedef struct { void *p; } SRWLOCK, *PSRWLOCK; typedef struct { void *p; } CONDITION_VARIABLE, *PCONDITION_VARIABLE; #define SRWLOCK_INIT {0} #define HOS_TLS_SLOTS 64 typedef struct { uint64_t x[32]; volatile int poll; } Ctx; static int64_t now_ns = 1000000000; static int64_t hos_now_ns(void) { return now_ns; } static int escapes_logged; static void hos_log(const char *fmt, ...) { if (strstr(fmt, "ran without a core")) escapes_logged++; } static int hos_cores(void) { return 2; } enum { HOS_CHAIN_WAIT = 1, HOS_CHAIN_WOKE, HOS_CHAIN_SIGNAL, HOS_CHAIN_SLEEP, HOS_CHAIN_AWAKE, HOS_CHAIN_PRESENT }; #define HOS_CHAIN(kind, key) do { (void)(kind); (void)(key); } while (0) @STRUCT@ void hos_vcore_checkpoint(void); static HosThread *cur; static HosThread *stamp_holder; static int probe_answers_left; /* -1: answer every probe; 0: none */ static int probes_answered; static int escapes_when_probes_stop = -1; static int sleeps; static BOOL SleepConditionVariableSRW(PCONDITION_VARIABLE cv, PSRWLOCK lk, DWORD ms, unsigned long f) { (void)cv, (void)lk, (void)f; now_ns += (int64_t)(ms ? ms : 1) * 1000000; sleeps++; if (sleeps > 3000) { puts("FAIL: no escape after 3 s of waiting (a static rule?)"); exit(2); } if (stamp_holder && stamp_holder->vc_kick == 3 && probe_answers_left != 0) { HosThread *save = cur; cur = stamp_holder; hos_vcore_checkpoint(); /* records the stamp, clears the probe, does not yield */ cur = save; probes_answered++; if (probe_answers_left > 0) probe_answers_left--; if (probe_answers_left == 0) escapes_when_probes_stop = escapes_logged; } return 1; } static void WakeConditionVariable(PCONDITION_VARIABLE cv) { (void)cv; } static void AcquireSRWLockExclusive(PSRWLOCK l) { (void)l; } static void ReleaseSRWLockExclusive(PSRWLOCK l) { (void)l; } @CORE@ @VCORES@ static int bad; static void check(int ok, const char *what) { if (!ok) { printf("FAIL: %s\n", what); bad = 1; } } static void thread(HosThread *t, const char *name, int prio, uint64_t mask) { memset(t, 0, sizeof *t); snprintf(t->name, sizeof t->name, "%s", name); t->priority = prio, t->core_mask = mask, t->ideal_core = -2; } static void hold(HosThread *t) { cur = t; hos_vcore_back(); } static void reset(void) { memset(vc_owner, 0, sizeof vc_owner); escapes_logged = 0; sleeps = 0; probes_answered = 0; escapes_when_probes_stop = -1; } int main(void) { setenv("HOS_VCORE_ESCAPE_US", "250000", 1); setenv("HOS_VCORE_ESCAPE_EQ_US", "250000", 1); HosThread h0, h1, t; /* 1. a fixed fresh stamp cannot keep the waiter waiting forever: the window rolls */ thread(&h0, "holder0", 16, 1), thread(&h1, "holder1", 16, 2), thread(&t, "waiter", 26, 3); reset(); hold(&h0), hold(&h1); h0.vc_last_poll = now_ns + 1000000; /* fresh when the wait starts, then never again */ h1.vc_last_poll = now_ns - 2000000000; stamp_holder = &h0, probe_answers_left = 0; cur = &t; hos_vcore_back(); check(escapes_logged == 1 && !t.vc_held, "a fresh but fixed stamp still trips the escape (rolling deadline)"); check(sleeps >= 250 && sleeps <= 900, "and within a bounded window after the stamp ages"); /* 2. probes count as progress while they are answered, and stop the escape only while they are */ thread(&h0, "holder0", 16, 1), thread(&h1, "holder1", 16, 2), thread(&t, "waiter", 26, 3); reset(); hold(&h0), hold(&h1); h0.vc_last_poll = now_ns - 2000000000, h1.vc_last_poll = now_ns - 2000000000; stamp_holder = &h0, probe_answers_left = 3; cur = &t; hos_vcore_back(); check(probes_answered >= 3, "the better holder was probed, not asked to yield"); check(escapes_when_probes_stop == 0, "no escape while the probes were answered"); check(escapes_logged == 1 && !t.vc_held, "the escape comes once the holder stops answering"); check(sleeps >= 250 && sleeps <= 900, "a full rolling window after the last answered probe"); /* 3. an unanswering holder trips the deadline at ~250 ms */ thread(&h0, "holder0", 16, 1), thread(&h1, "holder1", 16, 2), thread(&t, "waiter", 26, 3); reset(); hold(&h0), hold(&h1); h0.vc_last_poll = now_ns - 2000000000, h1.vc_last_poll = now_ns - 2000000000; stamp_holder = &h0, probe_answers_left = 0; cur = &t; hos_vcore_back(); check(escapes_logged == 1 && !t.vc_held, "a waiter escapes behind a holder that has not checkpointed"); check(sleeps >= 250 && sleeps <= 900, "only after its 250 ms deadline"); /* 4. only holders of the cores the waiter may use count */ thread(&h0, "holder0", 16, 1), thread(&h1, "holder1", 16, 2), thread(&t, "waiter", 26, 1); reset(); hold(&h0), hold(&h1); h0.vc_last_poll = now_ns - 2000000000; /* the waiter's core: stale */ h1.vc_last_poll = now_ns + 1000000; /* not its core: fresh */ stamp_holder = NULL, probe_answers_left = 0; cur = &t; hos_vcore_back(); check(escapes_logged == 1 && !t.vc_held, "a fresh holder on a core the waiter may not use is not progress"); /* 5. a checkpoint records the time; a probe checkpoint does not release the core */ thread(&h0, "holder0", 16, 1); memset(vc_owner, 0, sizeof vc_owner); h0.vc_held = 1, vc_owner[0] = &h0, h0.vc_kick = 3, h0.vc_last_poll = 0; now_ns += 5000000; cur = &h0; hos_vcore_checkpoint(); check(h0.vc_last_poll == now_ns, "a checkpoint records the time it was reached"); check(h0.vc_kick == 0 && h0.vc_held == 1, "a probe checkpoint is cleared without giving the core up"); if (!bad) puts("ok"); return bad; } """ def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--cxx", default="clang++") parser.add_argument("--source", type=Path, default=ROOT / "host/hos_thread.c") args = parser.parse_args() src = args.source.read_text(encoding="utf-8") start = src.index("static SRWLOCK vc_lock = SRWLOCK_INIT;") end = src.index("\n#else", src.index("int hos_cv_sleep(void *cv, void *lk, unsigned ms) {", start)) fixture = (HARNESS.replace("@STRUCT@", block(src, "struct HosThread {") + "\ntypedef struct HosThread HosThread;") .replace("@CORE@", block(src, "int hos_thread_core(HosThread *t) {")) .replace("@VCORES@", src[start:end])) compiler = shutil.which(args.cxx) if not compiler: raise SystemExit("C++ compiler not found: " + args.cxx) with tempfile.TemporaryDirectory(prefix="vcore-progress-") as temporary: folder = Path(temporary) (folder / "fixture.cpp").write_text(fixture, encoding="utf-8") exe = folder / ("fixture.exe" if os.name == "nt" else "fixture") subprocess.run([compiler, "-std=c++17", "-O1", "-w", str(folder / "fixture.cpp"), "-o", str(exe)], check=True, timeout=120) result = subprocess.run([str(exe)], capture_output=True, text=True, timeout=30) print(result.stdout, end="") if result.returncode or "ok" not in result.stdout: raise SystemExit("a waiter escapes while a holder on one of its cores is making progress (issue 1929)") if __name__ == "__main__": main() ``` --- 🤖 Generated with [opencode](https://opencode.ai) using DeepSeek V4.1 Flash.
mooki000 commented 2026-11-11 23:46:31 +00:00 (Migrated from github.com)

@CIB thanks for pasting it. I ran tests/test_vcore_progress.py exactly as posted, with g++ and with clang++; both give the same result:

host/hos_thread.c result
your 22:10 diff on 940eb9ef ok
your diff + the two-hunk change from my last comment ok
940eb9ef + your 17:31 static-rule hunk FAIL: no escape after 3 s of waiting (a static rule?)
main 940eb9ef does not compile: no member named 'vc_last_poll' (the field comes with your diff)

Both fixes are in. Case 1 sets a fresh stamp once and never moves it. Case 2 passes only if the holder answered a vc_kick == 3 probe at least three times (escapes_when_probes_stop starts at -1 and must end at 0). The test doesn't cover the shared-slot case, though: it passes on your diff, where my probe-slot test fails (1000 ms against 1 ms, on the harness's clock). So the two tests would sit well side by side. Our side of the matrix still waits for a quiet machine.

@CIB thanks for pasting it. I ran `tests/test_vcore_progress.py` exactly as posted, with g++ and with clang++; both give the same result: | `host/hos_thread.c` | result | |---|---| | your 22:10 diff on 940eb9ef | `ok` | | your diff + the two-hunk change from my last comment | `ok` | | 940eb9ef + your 17:31 static-rule hunk | `FAIL: no escape after 3 s of waiting (a static rule?)` | | main 940eb9ef | does not compile: `no member named 'vc_last_poll'` (the field comes with your diff) | Both fixes are in. Case 1 sets a fresh stamp once and never moves it. Case 2 passes only if the holder answered a `vc_kick == 3` probe at least three times (`escapes_when_probes_stop` starts at -1 and must end at 0). The test doesn't cover the shared-slot case, though: it passes on your diff, where my probe-slot test fails (1000 ms against 1 ms, on the harness's clock). So the two tests would sit well side by side. Our side of the matrix still waits for a quiet machine.
mooki000 commented 2026-11-12 00:22:38 +00:00 (Migrated from github.com)

@CIB here is our side of the matrix, on 940eb9ef with your 22:10 diff unchanged, two runs per arm. Settings as in our earlier runs: Arch, RTX 4090, NVIDIA 615.78.08, headless, a fresh profile, Start at 95 s, 115 s per run. The build's shader cache was empty, so every run went without the preparation (RX_VK_PRECOMPILE=0), after one uncounted warm-up run of the same steps.

arm escapes per run longest wait for a core (ms) load at start → end
250 ms, rule off 13, 12 251.1, 250.9 15.9 → 11.0, 25.0 → 17.4
250 ms, rule on 0, 0 ¹ 1379.2, 1181.3 11.0 → 19.8, 55.6 → 25.0
2 s, rule off 0 ¹, 0 1210.3, 789.2 19.8 → 40.9, 87.5 → 49.4
2 s, rule on 0 ¹, 0 1138.9, 1432.0 40.9 → 55.6, 17.4 → 109.0

¹ Another job's shader preparation ran beside that run, and the game ran at 34 to 47 fps; kept, not dropped. The waits are the longest from the game's 5-second vcores: lines.

  • With the rule off, every escape was behind a holder that was making progress. All 25 escapes stood behind a holder better than the waiter, and 24 of those holders had reached a checkpoint within 10 ms (your stamp field). BaseProcCreateAndDeleteThread was the holder at 8 (held 0.1 to 9.7 ms), ParallelDecompThread at 5 (held 0.1 to 1.4 ms) and sead::MainThread at 2 (held 312 and 333 ms, last checkpoint 0.1 to 0.2 ms before). With the rule on, none of those escapes was left. The same three threads lead your rule-off list.
  • Where we differ: at 250 ms with the rule on you had 7 escapes, all behind BootupBackgroundWorkThread; we had none. The thread runs here too, but it never held a core at an escape in any of our runs. Our runs are shorter than yours (20 s after Start against your 45 s), use the default memfd guest window rather than your anonymous one (HOS_GUEST_MEMFD=0), and skip the preparation, so I can't say which of these explains it.
  • What the rule costs here: at 250 ms the longest wait for a core went from 251 ms to 1.2 to 1.4 s. At the 2 s default both arms had waits of 0.8 to 1.4 s and no escapes, so there I see no difference between rule on and rule off. The kicks counter, which includes your probes, roughly triples with the rule on: 443,647 and 352,440 per run against 145,376 and 132,839 at 250 ms.

Not tested: Windows, anything more than 20 s after Start, a prepared shader cache, and the probe-slot case in the game (it needs two waiters' looks to fall in the wrong order).

@CIB here is our side of the matrix, on 940eb9ef with your 22:10 diff unchanged, two runs per arm. Settings as in our earlier runs: Arch, RTX 4090, NVIDIA 615.78.08, headless, a fresh profile, Start at 95 s, 115 s per run. The build's shader cache was empty, so every run went without the preparation (`RX_VK_PRECOMPILE=0`), after one uncounted warm-up run of the same steps. | arm | escapes per run | longest wait for a core (ms) | load at start → end | |---|---|---|---| | 250 ms, rule off | 13, 12 | 251.1, 250.9 | 15.9 → 11.0, 25.0 → 17.4 | | 250 ms, rule on | 0, 0 ¹ | 1379.2, 1181.3 | 11.0 → 19.8, 55.6 → 25.0 | | 2 s, rule off | 0 ¹, 0 | 1210.3, 789.2 | 19.8 → 40.9, 87.5 → 49.4 | | 2 s, rule on | 0 ¹, 0 | 1138.9, 1432.0 | 40.9 → 55.6, 17.4 → 109.0 | ¹ Another job's shader preparation ran beside that run, and the game ran at 34 to 47 fps; kept, not dropped. The waits are the longest from the game's 5-second `vcores:` lines. - **With the rule off, every escape was behind a holder that was making progress.** All 25 escapes stood behind a holder better than the waiter, and 24 of those holders had reached a checkpoint within 10 ms (your `stamp` field). `BaseProcCreateAndDeleteThread` was the holder at 8 (held 0.1 to 9.7 ms), `ParallelDecompThread` at 5 (held 0.1 to 1.4 ms) and `sead::MainThread` at 2 (held 312 and 333 ms, last checkpoint 0.1 to 0.2 ms before). With the rule on, none of those escapes was left. The same three threads lead your rule-off list. - **Where we differ:** at 250 ms with the rule on you had 7 escapes, all behind `BootupBackgroundWorkThread`; we had none. The thread runs here too, but it never held a core at an escape in any of our runs. Our runs are shorter than yours (20 s after Start against your 45 s), use the default memfd guest window rather than your anonymous one (`HOS_GUEST_MEMFD=0`), and skip the preparation, so I can't say which of these explains it. - **What the rule costs here:** at 250 ms the longest wait for a core went from 251 ms to 1.2 to 1.4 s. At the 2 s default both arms had waits of 0.8 to 1.4 s and no escapes, so there I see no difference between rule on and rule off. The `kicks` counter, which includes your probes, roughly triples with the rule on: 443,647 and 352,440 per run against 145,376 and 132,839 at 250 ms. Not tested: Windows, anything more than 20 s after Start, a prepared shader cache, and the probe-slot case in the game (it needs two waiters' looks to fall in the wrong order).
Owner

@CIB commented on GitHub · 2026-11-12 15:40 UTC · original

Status update: the rule is not the fix

I agree with the audit conclusion and mooki's matrix: the progress-rule + probe is a mitigation, not the right solution. It leaves the escape as a tokenless identity violation, it can mask a spin-waiting better holder, it has no measured benefit at the 2 s default, and at 250 ms it trades escapes for 1.2-1.4 s core waits. I am not proposing it as the fix.

The direction I am taking is the deeper one: stable home core, no escapes, and host waits releasing their cores. Before changing that invariant I am building debug-only vcore diagnostics - a low-overhead event ring, strict token/live/TLS-core identity checks, a watchdog, and host-block markers - all off by default. Its job is to name the real culprits (host lock, host syscall, fiber, guest spin, scheduler) instead of inferring them.

I will open a PR for the diagnostics when it is validated, and keep the scheduler refactor for after that. No PR is open yet.


🤖 Generated with opencode using DeepSeek V4.1 Flash.

> [@CIB](https://github.com/CIB) commented on GitHub · 2026-11-12 15:40 UTC · [original](https://github.com/NotRexed/Hookshot/issues/1929#issuecomment-6828180364) ### Status update: the rule is not the fix I agree with the audit conclusion and mooki's matrix: the progress-rule + probe is a mitigation, not the right solution. It leaves the escape as a tokenless identity violation, it can mask a spin-waiting better holder, it has no measured benefit at the 2 s default, and at 250 ms it trades escapes for 1.2-1.4 s core waits. I am not proposing it as the fix. The direction I am taking is the deeper one: stable home core, no escapes, and host waits releasing their cores. Before changing that invariant I am building debug-only vcore diagnostics - a low-overhead event ring, strict token/live/TLS-core identity checks, a watchdog, and host-block markers - all off by default. Its job is to name the real culprits (host lock, host syscall, fiber, guest spin, scheduler) instead of inferring them. I will open a PR for the diagnostics when it is validated, and keep the scheduler refactor for after that. No PR is open yet. --- 🤖 Generated with [opencode](https://opencode.ai) using DeepSeek V4.1 Flash. <!-- github-sync:c:6828180364 -->
Commenting is not possible because the repository is archived.
No milestone
No assignees
2 participants
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
MagicBeanDev/Hookshot#1929
No description provided.