Skip to content

Instantly share code, notes, and snippets.

@nem0
Created January 20, 2026 23:26
Show Gist options
  • Select an option

  • Save nem0/76409a5abda1610373e51ccb323b2cb3 to your computer and use it in GitHub Desktop.

Select an option

Save nem0/76409a5abda1610373e51ccb323b2cb3 to your computer and use it in GitHub Desktop.
LLM one-shots a deadlock
// LLM one-shots a deadlock - https://github.com/nem0/LumixEngine/issues/1534
// Claude Opus 4.5.
User: When I add a dynamic point light with shadows to a scene with a few objects and move the camera and objects for a while, the editor freezes. All worker threads are in this callstack
[External Code]
> studio.exe!Lumix::ConditionVariable::sleep(Lumix::Mutex & mutex) Line 54 C++
studio.exe!Lumix::Thread::sleep(Lumix::Mutex & mutex) Line 149 C++
studio.exe!Lumix::jobs::popWork(Lumix::jobs::Work & work, Lumix::jobs::WorkerTask * worker) Line 401 C++
studio.exe!Lumix::jobs::manage(void * data) Line 561 C++
[External Code]
What's the issue?
GitHub Copilot: Read [](file:///c%3A/projects/LumixEngine/src/core/job_system.cpp#380-380), lines 380 to 600
Searched for text `point light shadow`, no results
Searched for text `pointLightShadow`, no results
Searched for text `PointLightShadow`, no results
Searched for regex `point.*shadow|shadow.*point`, 15 results
Searched for text `bakeShadow`, 3 results
Read [](file:///c%3A/projects/LumixEngine/src/renderer/pipeline.cpp#3350-3350), lines 3350 to 3500
Let me look at the `bakeShadow` function more closely:
Read [](file:///c%3A/projects/LumixEngine/src/renderer/pipeline.cpp#820-820), lines 820 to 950
Now I need to look at the `cull` function and the `renderBucket` function to understand the job system usage:
Searched for regex `u32 cull\(` (`**/pipeline.cpp`), 1 result
Read [](file:///c%3A/projects/LumixEngine/src/renderer/pipeline.cpp#990-990), lines 990 to 1150
Now let me look at `createSortKeys` to understand the jobs system usage and the potential deadlock:
Searched for text `createSortKeys` (`**/pipeline.cpp`), 3 results
Read [](file:///c%3A/projects/LumixEngine/src/renderer/pipeline.cpp#3770-3770), lines 3770 to 3950
Now let me look at what happens after `createSortKeys` and understand the synchronization:
Read [](file:///c%3A/projects/LumixEngine/src/renderer/pipeline.cpp#1025-1025), lines 1025 to 1070
Let me understand the job system's signal mechanism better, and see how `pushJob` works:
Searched for text `pushJob` (`**/renderer.cpp`), no results
Searched for files matching `**/renderer.cpp`, 1 match
Searched for text `void pushJob`, 2 results
Read [](file:///c%3A/projects/LumixEngine/src/renderer/renderer.h#110-110), lines 110 to 180
Now let me understand the `setupJob` function:
Searched for text `setupJob` (`**/renderer.cpp`), 1 result
Read [](file:///c%3A/projects/LumixEngine/src/renderer/renderer.cpp#990-990), lines 990 to 1030
Now I see that `setupJob` uses `jobs::run` with a signal `m_cpu_frame->setup_done`. Let me look at how the frame synchronization works:
Searched for text `setup_done` (`**/renderer.cpp`), 4 results
Read [](file:///c%3A/projects/LumixEngine/src/renderer/renderer.cpp#1140-1140), lines 1140 to 1270
Now let me understand what happens in the `bakeShadow` function when it's inside the "fill clusters" job. Let me look at the entire flow more carefully:
Read [](file:///c%3A/projects/LumixEngine/src/renderer/pipeline.cpp#3410-3410), lines 3410 to 3440
I see now! The `bakeShadow` calls are happening **before** the `pushJob("fill clusters")`, not inside it. Let me look more closely at what `bakeShadow` does, particularly the `cull` and `renderBucket` calls inside it:
Read [](file:///c%3A/projects/LumixEngine/src/renderer/pipeline.cpp#830-830), lines 830 to 920
Now I see the pattern. `bakeShadow` calls `cull()` which in turn calls `m_renderer.pushJob()`. Let me look again at the `cull` function to see what waits are set up:
Read [](file:///c%3A/projects/LumixEngine/src/renderer/pipeline.cpp#985-985), lines 985 to 1060
I see the issue now. In the `cull` function:
1. `m_sort_keys_group.beginJob()` is called
2. Inside the "prepare view" job, `m_sort_keys_group.endJob()` is called
3. Later in the same job, `m_sort_keys_group.wait()` is called
When `bakeShadow` is called for 6 faces (omnidirectional light), it calls `cull` 6 times. Each call to `cull` does `m_sort_keys_group.beginJob()`, but only one `endJob()` can happen per job. Let me look at the `JobGroup` implementation:
Searched for text `struct JobGroup`, no results
Searched for text `m_sort_keys_group`, 9 results
Searched for text `struct JobsGroup`, 1 result
Read [](file:///c%3A/projects/LumixEngine/src/renderer/pipeline.cpp#275-275), lines 275 to 350
Now I understand the issue. Looking at the code flow:
1. `bakeShadow` is called from line 3421/3424 in `fillClusters`
2. `bakeShadow` calls `cull` for each of the 6 faces (omnidirectional light)
3. Each `cull` call does `m_sort_keys_group.beginJob()` which increments the counter
4. Then it pushes a "prepare view" job that will call `m_sort_keys_group.endJob()` and later `m_sort_keys_group.wait()`
The problem is that `m_sort_keys_group` is a single shared instance for the entire pipeline. When multiple `cull` calls are made (e.g., 6 times for an omnidirectional light), the `beginJob` counter is incremented 6 times, but the `wait` in any of the "prepare view" jobs will wait for ALL of them to complete.
But wait - the more critical issue is that if a "prepare view" job waits on `m_sort_keys_group.wait()`, and there are other "prepare view" jobs that haven't run yet (which would call `endJob()`), this creates a potential deadlock if all worker threads are blocked waiting.
Let me look at how jobs are scheduled and whether there's a finite pool of workers:
Continue to iterate?
[object Object]
User: @agent Continue: "Continue to iterate?"
GitHub Copilot: Read [](file:///c%3A/projects/LumixEngine/src/core/job_system.cpp#1-1), lines 1 to 100
Searched for text `getWorkersCount` (`**/job_system.cpp`), 2 results
Read [](file:///c%3A/projects/LumixEngine/src/core/job_system.cpp#615-615), lines 615 to 650
Now let me look at how `jobs::wait` works, especially to understand if fibers are involved:
Searched for text `void wait` (`**/job_system.cpp`), 3 results
Read [](file:///c%3A/projects/LumixEngine/src/core/job_system.cpp#655-655), lines 655 to 720
Now I understand the job system better. When `wait` is called, the current fiber is parked and the worker switches to another fiber to do other work. This is fiber-based job scheduling, which should handle waiting inside jobs well.
Let me look more closely at the fiber pool:
Searched for text `m_fiber_pool` (`**/job_system.cpp`), 3 results
Searched for text `popFreeFiber` (`**/job_system.cpp`), 4 results
Read [](file:///c%3A/projects/LumixEngine/src/core/job_system.cpp#240-240), lines 240 to 290
I see that `popFreeFiber` asserts if there's no free fiber. Let me check if there's ever a case where all fibers are exhausted:
Read [](file:///c%3A/projects/LumixEngine/src/core/job_system.cpp#285-285), lines 285 to 330
Now let me look at the issue from a different angle. The freeze happens when all worker threads are sleeping waiting for work. Let me look at what happens with the `m_poses_done` signal:
Searched for text `m_poses_done` (`**/pipeline.cpp`), 3 results
Read [](file:///c%3A/projects/LumixEngine/src/renderer/pipeline.cpp#3745-3745), lines 3745 to 3780
Now let me look at the key synchronization points. The issue might be related to how the "prepare view" job waits for `m_sort_keys_group` and `m_poses_done`. Let me trace the execution more carefully.
Let me understand the flow when `bakeShadow` is called:
1. `fillClusters` calls `bakeShadow` for dynamic point lights
2. `bakeShadow` (for omnidirectional light) calls `cull` 6 times (for each face)
3. Each `cull` call:
- Creates a new view
- Calls `m_sort_keys_group.beginJob()`
- Pushes a "prepare view" job that will eventually call `m_sort_keys_group.endJob()` and later `m_sort_keys_group.wait()`
Wait, I think I see the issue! In `cull`, there's:
- `m_sort_keys_group.beginJob()` (line 1029)
- Then "prepare view" job calls `m_sort_keys_group.endJob()` (line 1041) and then `m_sort_keys_group.wait()` (line 1047)
But `m_sort_keys_group` is a shared member! So when `bakeShadow` is called for 6 faces:
1. First `cull` calls `beginJob()` → counter = 1 (and turns signal red)
2. Second `cull` calls `beginJob()` → counter = 2
3. ... continues until counter = 6
4. First "prepare view" job runs, calls `endJob()` → counter = 5
5. First "prepare view" job then calls `wait()` but counter is still 5, so it blocks waiting for all 6 views to finish!
But wait, the "prepare view" jobs also have their own internal work via `createSortKeys` which uses `jobs::runOnWorkers`. Let me look at this:
Read [](file:///c%3A/projects/LumixEngine/src/renderer/pipeline.cpp#3800-3800), lines 3800 to 3820
Searched for text `runOnWorkers` (`**/job_system.cpp`), no results
Searched for text `void runOnWorkers`, 2 results
Read [](file:///c%3A/projects/LumixEngine/src/core/job_system.h#115-115), lines 115 to 145
Now I understand the issue better. `runOnWorkers` runs on all workers and **waits for all to complete** synchronously.
Inside the "prepare view" job, `createSortKeys` is called which internally calls `jobs::runOnWorkers`. So if a "prepare view" job is waiting at `m_sort_keys_group.wait()` while another "prepare view" hasn't been picked up yet, we have a potential deadlock.
Let me trace again with concrete flow:
1. `fillClusters` is called on the main thread
2. `bakeShadow` is called, and for omnidirectional light it runs a loop 6 times
3. For each iteration of the loop, `cull` is called which pushes a "prepare view" job
4. Each "prepare view" job:
- Runs on any worker
- Calls `createSortKeys` which uses `jobs::runOnWorkers` (blocks until all workers process)
- Then waits on `m_sort_keys_group`
The critical issue is that `jobs::runOnWorkers` requires **all workers** to run the job, but if some workers are already blocked waiting on `m_sort_keys_group.wait()`, there won't be enough free workers!
Wait, but the fiber system should handle this. When `wait` is called, the fiber yields and the worker picks up new work.
Let me re-read the flow for the edge case...
Actually, let me look at whether the issue is about the `m_sort_keys_group` shared state between multiple `cull` calls:
Read [](file:///c%3A/projects/LumixEngine/src/renderer/pipeline.cpp#275-275), lines 275 to 310
I see the issue now! In `JobsGroup`:
- `begin()` sets `counter = 1` and turns signal red
- `beginJob()` just increments the counter
- `endJob()` decrements and turns green **only if counter becomes 0 (after dec, it's 1 before dec)**
- `wait()` waits for the signal to be green
The problem is in `cull`:
- Line 1029: `m_sort_keys_group.beginJob()` is called (not `begin()`)!
But `beginJob()` just increments the counter. It doesn't set `counter = 1` or turn the signal red! Let me trace what happens:
1. First `cull`: `beginJob()` increments counter (0 → 1), but **signal is still green!**
- Later, `endJob()` decrements (1 → 0), turning signal green (it was already green)
- `wait()` on green signal returns immediately
2. Second `cull`: Same thing...
Wait, there's an issue. `beginJob()` doesn't turn the signal red when counter goes 0 → 1. Let me check if `begin()` is ever called:
Searched for regex `m_sort_keys_group\.(begin|end)\(` (`**/pipeline.cpp`), 3 results
Read [](file:///c%3A/projects/LumixEngine/src/renderer/pipeline.cpp#1570-1570), lines 1570 to 1660
Now I understand the design better. `renderMain()` calls `m_sort_keys_group.begin()` and `m_sort_keys_group.end()` to bracket the entire main render pass. The `cull` function calls `beginJob()` and `endJob()` to track individual "prepare view" jobs.
But the problem is: `cull` is being called from `bakeShadow` which is called **before** `renderMain` is called in `fillClusters`!
Let me look at where `fillClusters` is called:
Searched for text `fillClusters` (`**/pipeline.cpp`), 2 results
Read [](file:///c%3A/projects/LumixEngine/src/renderer/pipeline.cpp#1800-1800), lines 1800 to 1850
I see now - `fillClusters` is called **before** `renderMain`. So when `bakeShadow` is called inside `fillClusters`, `m_sort_keys_group.begin()` has **not** been called yet.
This means when `cull` calls `m_sort_keys_group.beginJob()`:
- If counter was 0 and signal was green, `beginJob()` just increments counter to 1
- But it doesn't turn the signal red!
When `endJob()` is called:
- Counter goes 1 → 0
- Since `counter.dec() == 1` (the value before decrement was 1), it turns green... but it was already green!
When `wait()` is called:
- Signal is green, so it should return immediately
But wait, that should work then. Let me look at `beginJob()` more carefully:
```cpp
void beginJob() {
counter.inc();
}
```
And `endJob()`:
```cpp
void endJob() {
if (counter.dec() == 1) {
jobs::turnGreen(&signal);
}
}
```
The condition `counter.dec() == 1` means the value **before decrement** was 1 (so after decrement it's 0). This should work correctly...
But wait! Let me look at the signal management. When `cull` calls `beginJob()`, if the counter goes from 0 to 1, **the signal should turn red** but it doesn't! Let me think about the race condition...
Actually, let me look at what happens when `renderMain` is called after `fillClusters`:
1. `fillClusters` calls `bakeShadow` which calls `cull` multiple times
2. Each `cull` call does `beginJob()` incrementing counter
3. Each corresponding "prepare view" job does `endJob()` decrementing counter
4. Then `renderMain` calls `m_sort_keys_group.begin()` which sets `counter = 1` and turns signal red!
The issue is: if a "prepare view" job from `bakeShadow` hasn't finished yet when `renderMain().begin()` is called, the `counter = 1` assignment will **reset the counter**, potentially corrupting the state!
Actually, let me trace more carefully:
1. `bakeShadow` is called for omnidirectional light
2. For i=0: `cull` is called, `beginJob()` → counter becomes 1
3. For i=0: `renderBucket` is called, which pushes a "render bucket" job that waits on `view->ready`
4. For i=1: `cull` is called, `beginJob()` → counter becomes 2
5. ...
6. After all 6 iterations, counter = 6
7. Later: "prepare view" job for i=0 runs, calls `endJob()` → counter becomes 5, signal still red
8. ...
Wait, but when is the signal turned red initially? `beginJob()` doesn't turn it red!
Looking at the code again:
```cpp
void beginJob() {
counter.inc();
}
```
If the initial state is counter=0, signal=green, then after `beginJob()`, counter=1, signal=green.
When the "prepare view" job calls:
1. `endJob()` → counter goes 1→0, since `dec() == 1`, it calls `turnGreen()` (which is a no-op since already green)
2. `wait()` → signal is green, returns immediately
This should work... but the freeze still happens. Let me look at what happens when multiple `bakeShadow` calls interleave:
Actually, I realize the issue might be with the comment on line 1046:
```cpp
// Wait for all createSortKeys jobs to finish, ensuring no more pose processing jobs will be created.
m_sort_keys_group.wait();
```
The `createSortKeys` function uses `jobs::runOnWorkers` internally. Let me trace:
1. "prepare view" job starts
2. Calls `createSortKeys(*view_ptr)` which internally calls `jobs::runOnWorkers([&](){ ... })`
3. `runOnWorkers` runs the lambda on all workers and **blocks** until all complete
4. After `createSortKeys` returns, `m_sort_keys_group.endJob()` is called
5. Then `m_sort_keys_group.wait()` is called
The issue is: `runOnWorkers` blocks the current fiber waiting for all workers. But if multiple "prepare view" jobs are running, and they all call `runOnWorkers`, they compete for the worker threads.
Actually, I think the fiber system should handle this. When `runOnWorkers` waits, the fiber yields.
Let me look at the actual issue from a different angle. The callstack shows workers are sleeping waiting for work. This means there's no work in any queue. Let me look at what could cause all work to be consumed without producing more:
Read [](file:///c%3A/projects/LumixEngine/src/renderer/pipeline.cpp#1025-1025), lines 1025 to 1060
I found the bug! Look at the logic in the "prepare view" job:
```cpp
if (view_ptr->renderables && (view_ptr->renderables->header.count != 0 || view_ptr->renderables->header.next)) {
createSortKeys(*view_ptr);
view_ptr->renderables->free(m_renderer.getEngine().getPageAllocator());
}
m_sort_keys_group.endJob(); // This is OUTSIDE the if!
view_ptr->sorter.pack();
if (!view_ptr->sorter.keys.empty()) {
radixSort(...);
m_sort_keys_group.wait(); // This is INSIDE the second if!
jobs::wait(&m_poses_done);
createCommands(*view_ptr);
}
```
The problem is:
1. `m_sort_keys_group.endJob()` is called unconditionally
2. `m_sort_keys_group.wait()` is called **only if** `!view_ptr->sorter.keys.empty()`
When `bakeShadow` is called and creates 6 views (for omnidirectional light), the counter starts at some value. After all 6 `cull` calls, counter = 6 (assuming it started at 0).
Now, if some of these views have empty renderables (nothing visible from that face):
- "prepare view" job runs
- `endJob()` is called → counter decrements
- But `wait()` is NOT called because `sorter.keys.empty()` is true!
- This view's job is done
For views that DO have renderables:
- `endJob()` is called → counter decrements
- `wait()` IS called
- But it will wait forever if there are still pending jobs that haven't called `endJob()` yet!
Wait, but the issue is actually the opposite. Let me think again...
If counter = 6 after 6 `cull` calls:
- All 6 "prepare view" jobs call `endJob()` regardless of whether they have renderables
- Counter goes 6 → 5 → 4 → 3 → 2 → 1 → 0
- When it hits 0, signal turns green
But then some jobs call `wait()` and some don't. The jobs that call `wait()` wait for the signal to turn green, which happens when counter = 0.
Actually, the logic seems correct. Let me look more carefully at `beginJob()`:
```cpp
void beginJob() {
counter.inc();
}
```
This doesn't turn the signal red! So if the signal was already green and counter was 0:
1. First `cull`: `beginJob()` → counter = 1, signal still green
2. Second `cull`: `beginJob()` → counter = 2, signal still green
3. ... counter = 6, signal still green
4. First "prepare view" runs, `endJob()` → counter = 5, signal green (no change because counter > 1)
5. ... until counter = 1
6. Last `endJob()` → counter = 0, signal turns green (was already green, no-op)
7. `wait()` returns immediately because signal is green
This should work... unless there's a race condition where `renderMain` starts before all `bakeShadow` views are processed!
Let me look at the execution order again:
```cpp
if (only_2d) {
render2DOnly();
}
else {
fillClusters(stream, getMainCamera()); // This calls bakeShadow which calls cull
renderMain(); // This calls m_sort_keys_group.begin() setting counter = 1!
}
```
The issue is:
1. `fillClusters` calls `bakeShadow` which calls `cull` 6 times, incrementing counter to 6
2. 6 "prepare view" jobs are pushed to the job queue
3. `fillClusters` returns
4. `renderMain` is called
5. `renderMain` calls `m_sort_keys_group.begin()` which does `counter = 1` and `turnRed(&signal)`!
Now counter = 1 (not 6!), and signal is red. But there are still 6 pending "prepare view" jobs from `bakeShadow`!
When those jobs run:
- They call `endJob()` → counter goes 1 → 0 → **-1** → **-2** (underflow!)
- The signal state becomes corrupted!
Or worse, if one "prepare view" job runs before `renderMain`:
- It calls `endJob()` → counter goes 6 → 5
- Then `renderMain` calls `begin()` → counter = 1, signal red
- Other "prepare view" jobs call `endJob()` → counter goes 1 → 0 → -1 → ...
This is the bug! `m_sort_keys_group.begin()` in `renderMain` corrupts the state set up by `bakeShadow`'s `cull` calls.
The fix should be to NOT call `m_sort_keys_group.begin()` and `m_sort_keys_group.end()` in `renderMain`, OR to ensure that `bakeShadow` uses a separate `JobsGroup` or doesn't use `cull` (which uses the shared `m_sort_keys_group`).
Let me verify by looking at how `bakeShadow` works:
Read [](file:///c%3A/projects/LumixEngine/src/renderer/pipeline.cpp#920-920), lines 920 to 990
Yes, I can confirm that `bakeShadow` calls `cull` (line 879 and 943) which uses `m_sort_keys_group.beginJob()`.
The fix should be to ensure that `bakeShadow`'s use of `cull` doesn't interfere with `renderMain`'s use of `m_sort_keys_group`.
The cleanest fix would be to:
1. Move the `m_sort_keys_group.begin()` call to **before** `fillClusters`
2. Or use a separate tracking mechanism for `bakeShadow`
Let me look at the intended design - the `begin()`/`end()` pair should bracket all `beginJob()`/`endJob()` pairs. Looking at line 1581:
```cpp
void renderMain() {
m_sort_keys_group.begin();
...
```
This suggests that the design intended all `cull` calls to happen AFTER `begin()` is called. But `fillClusters` is called BEFORE `renderMain`, breaking this assumption.
The fix should move `m_sort_keys_group.begin()` to happen before `fillClusters`:
Read [](file:///c%3A/projects/LumixEngine/src/renderer/pipeline.cpp#1780-1780), lines 1780 to 1825
Now I see the code structure. The fix should move `m_sort_keys_group.begin()` to before `fillClusters`. Let me look at `renderMain()` again to understand what else uses the group:
Read [](file:///c%3A/projects/LumixEngine/src/renderer/pipeline.cpp#1580-1580), lines 1580 to 1620
Now I understand the full picture. The `m_sort_keys_group.begin()` is called at the start of `renderMain()` and `end()` is called at the end. The issue is that `fillClusters` (called before `renderMain`) also uses `cull` which calls `beginJob()`.
The fix is to move `m_sort_keys_group.begin()` to before `fillClusters` is called. Let me implement this:
Using "Multi-Replace String in Files"
```
```
Now let me verify the changes and build to make sure there are no compile errors:
Read [](file:///c%3A/projects/LumixEngine/src/renderer/pipeline.cpp#1810-1810), lines 1810 to 1825
Read [](file:///c%3A/projects/LumixEngine/src/renderer/pipeline.cpp#1578-1578), lines 1578 to 1590
The changes look correct. Now let me build to verify there are no compile errors:
Ran `Build All (Debug)`
Made changes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment