Skip to content

drm/asahi: g17: spare and bound the work a GPU recovery dropped (a bystander compositor keeps its device) - #1240

Merged
RyanTheTide merged 3 commits into
aurora-silicon:integration/aurorafrom
rolotrealanis98:pr/g17-recovery-dropped-work
Nov 9, 2026
Merged

RyanTheTide merged 3 commits into
aurora-silicon:integration/aurorafrom
rolotrealanis98:pr/g17-recovery-dropped-work

Conversation

@rolotrealanis98

Copy link
Copy Markdown
Contributor

What this is

Two fixes to the G17 recovery path, on integration/aurora (bdb9994). They change only what happens to work that a GPU recovery dropped before it ran, on queues the recovery did not blame:

  1. drm/asahi: g17: Fix a GPU recovery failing the compositor for compute work it dropped. Compute commands that come back unexecuted (ENODATA) after crossing a recovery, with no fault reported on their queue, are classified Spared before the queue is quarantined, as the render side already does for the groups a restart dropped. They replay when their records prove they never ran, otherwise fail with ENODATA, and in neither case touch their VM's status. A zero-duration record without a recovery in between stays the firmware's report of the queue's own failed command (Guilty, fails the VM as before), and a fault report that arrives late reclassifies spared work Guilty. poisons() no longer treats ENODATA as poisoning unless the command is guilty.
  2. drm/asahi: g17: Fix fences of work a GPU recovery dropped failing as a lost device or never signalling. Three paths let spared work either cost its client the device or never signal: the spared compute queue's job watchdog failed it with ETIMEDOUT (which fail_while_owned() mirrors into the VM status as a lost device), now ENODATA; a spared queue with an unticketed entry waited for a witness that can never come, now fails at once with ENODATA, and timeout_head() reports such a queue so other VMs' watchdogs defer to it within their bounded renewals; render groups parked for replay that do not publish within 10 s made replay_render_front() return ETIMEDOUT, which failed the GPU event service and every client's device, now they fail Spared/ENODATA and the replay pass goes on. No new timeout: the compute bound is the job's scheduler watchdog (2 s per expiry, bounded renewals: about 2 s typically, 38 s worst on M5), the render replay bound stays 10 s.

The problem

On the J716s (MacBook Pro 16-inch, M5 Pro, G17S) a GPU fault in one client took the compositor down every time. On M5 every firmware restart is attributed to the firmware and classify() names nobody; the compute commands the recovery dropped then come back ENODATA, unclassified, and unclassified ENODATA poisoned the VM: the log said GPU work of a client failed (ENODATA, ...); the client sees a lost device for Hyprland right after the faulting client's EIO, zink segfaulted in dri_create_fence_fd, and Hyprland died. Three times in one session on 2026-11-08 (10:35, 10:40, 10:50 UTC) from Chromium-class clients; once the panel stayed black across compositor restarts (the display half of that is the companion drm/apple PR "Fix a GPU fence that never signals wedging the display"). Tracker: rolotrealanis98/m4-m5-linux#36. Analysis: GPU-RECOVERY-KILLS-COMPOSITOR.md and recovery/REPORT.md.

The faulting client still fails as before: a blamed render with EIO, a watchdog timeout with ETIMEDOUT, a reported compute fault now classifies its queue's work Guilty first. What changes is only work that never ran on a queue nobody blamed. A spared command is replayed only when its record shows it never ran, and a second fault on a replayed command is blamed on its VM (oldest_spared() counts replays), so the cost is at most one more recovery.

Hardware result (J716s, G17S, firmware 27.3 beta 1 26C5035r)

Kernel 7.3.0-g12c445fa5371: our J716s tree (integration/aurora 33bec40 + #1012 + #1173 + the J716s DCP/board series) with these two commits, the drm/apple bounded wait (companion PR) and the two recovery-diagnostic commits (the "Name the cause and the clients of every M5 GPU recovery" and "M5 GPU fault experiment parameters" PRs) on top. Hyprland on zink, DCP display at 3456x2234@120 with VRR. Load for 10 min: 3x vkcube, glmark2-es2-wayland, two Qt Quick windows on the GPU backend, a Chromium WebGL tab (RESULTS.md 2026-11-08 12:08 UTC):

  • 3 GPU recoveries within 3 s (uptime 518-521 s), all with glmark2's 10-command queues in flight (the firmware's recovery record, read by the cause-log PR, says "DSF pause timeout", reason 5, no queue blamed). Only glmark2 lost its device (one GPU work of a client failed (EIO, ...) line). Hyprland kept its pid through the run, vkcube x3, both Qt Quick windows and Chromium kept running; no crash report, no page-flip is awaiting, no RCU stall or hung task; swaps continued (1024 per 10 s after the load). The remaining 9.5 min of load produced no recovery.
  • Before these commits, on the same tree, every recovery ended with Hyprland's ENODATA ... lost device line and a zink SIGSEGV.
  • The same kernel has been the machine's daily since 12:45 UTC (RESULTS.md 14:19).

Not observed on hardware: the spared-compute watchdog path (ENODATA in place of ETIMEDOUT) and the stalled-render-replay path of commit 2 did not fire in these runs (no replay of N parked groups stalled line); commit 2 is validated as a no-regression there and by reading. An OOM at 12:47 UTC on this kernel was a Hyprland 0.57.1 direct-scanout retry storm (519 swapchain reconfigures, 1557 31 MB GBM buffers never released; the memory came back when the process died), not the kernel: RESULTS.md 12:43 and 13:15. No recovery ran in that boot.

Checks

  • Both patches apply to integration/aurora bdb9994 without conflict. Commit 1 differs from the tested patch by one 5-line addition: Completion::signalled() (a read of the existing signalled flag), which the tested tree had from drm/asahi, drm/apple: M5 GPU and firmware-27 display on integration/aurora #1012 and integration/aurora does not; note_fault() uses it to leave an already signalled spared command alone. Commit 2 is the tested diff.
  • Compile: make W=1 CLIPPY=1 drivers/gpu/drm/asahi/ drivers/gpu/drm/apple/ (LLVM arm64, defconfig + asahi.config, rustc 1.99.1, clang 21) at the base and at each commit, objects rebuilt from scratch: see the Checks line below.
  • checkpatch.pl --strict: 0 errors, 0 warnings, 0 checks on both.
  • No KUnit case added; the render side's existing spared/dropped tests are unchanged.

Compile check result: drivers/gpu/drm/asahi/ + drivers/gpu/drm/apple/ with W=1 CLIPPY=1 give the same 68 warnings at the base (bdb9994), at commit 1 and at commit 2; none new, none gone (all pre-existing clippy and deprecation notes). rustfmt on the .rs files each commit touches: no new deviation (the two files that already differ from rustfmt differ by the same hunks before and after).

Risks

  • A compute command the recovery dropped and that was actually the one faulting (unreported by the firmware) is now spared once; its replay faults again and is blamed on its VM. One extra recovery in that case.
  • timeout_head() now reports a spared queue whose fences still wait, so other VMs' watchdogs defer to it within their bounded renewals rather than fail their own work at once: up to one more renewal round for them.

Questions

  • Would you rather have the two commits squashed? Commit 1 is the isolation, commit 2 the bounds; each stands alone.
  • Is a KUnit case for the dropped-compute classification (a bystander compute queue whose front is unexecuted after a firmware-attributed restart: ENODATA on its fence, VM status 0) wanted here? We can add one.

… work it dropped

When a GPU recovery cannot name the culprit (on M5 every firmware
restart is attributed to the firmware, and classify() gives up when a
guilty queue has no in-flight owner), it classifies nobody. Compute
commands it dropped before they ran then come back from their queue's
records as unexecuted (ENODATA), unclassified, and poisons() is true for
unclassified ENODATA: the queue's terminal records the error and fails
every queue of the VM, and the client's driver reads the VM status as a
lost device. A Chromium GPU fault took Hyprland down this way: right
after the faulting client's EIO, the log reported "GPU work of a client
failed (ENODATA, ...)" for Hyprland too, and zink lost its device.

The render side already handles this: groups a restart dropped are
classified Spared before they fail with ENODATA. Do the same for
compute:

- When a compute queue's front comes back unexecuted after crossing a
  recovery, and the firmware reported no fault on the queue, classify
  every unclassified command of the queue Spared before quarantining it.
  All of them are ordered behind the front, so none of them ran. The
  queue then takes the existing spared path: its commands replay when
  their records prove they never ran, and otherwise fail with ENODATA
  without touching the VM status.
- Without a recovery in between, a zero-duration record is the
  firmware's report of the queue's own failed command (see note_fault()).
  Those commands are classified Guilty and fail the VM as before. A
  fault report that arrives after the commands were spared as dropped
  reclassifies them Guilty, stops the queue from being replayed or
  returned to service, and queues the VM failure the report would have
  caused.
- A compute replay that cannot be published was spared work: classify
  it Spared (unless guilty) before it fails with ENODATA, as dropped
  render groups are.
- poisons(): ENODATA no longer poisons unless the command is guilty. A
  command that never ran cannot have faulted the GPU; a blamed render
  still fails with EIO, and a faulting compute command is classified
  Guilty first.

The faulting client still fails: a blamed render fails with EIO, a
watchdog timeout with ETIMEDOUT, and a reported compute fault now
classifies its queue's work Guilty. What changes is only work that never
ran on a queue nobody blamed. When the recovery attributes nothing, the
command that was running when the firmware stopped and comes back with
no record is spared too: its fence still fails with ENODATA, but its VM
is not poisoned. A spared command is replayed only when its record shows
it never ran, and a second fault on a replayed command is blamed on its
VM (oldest_spared() counts replays), so this costs at most one more
recovery.

Completion::signalled() is added for note_fault(): a spared command whose
fence already signalled is not reclassified.

Signed-off-by: Rolando Trevino <rolotrealanis@gmail.com>
Assisted-by: LLM Claude
…a lost device or never signalling

A fence on a scanout buffer that never signals wedges KMS: the atomic
commit worker waits for it with no timeout, so no frame reaches the
display again, even across compositor restarts. Work a recovery dropped
must signal within a bound, and since it never ran, it must not cost
its client the device either. Three paths did not do that:

- A compute queue whose commands a recovery dropped waits, spared, for
  its retirement witness before it replays or fails them. When the
  queue's job watchdog expires first, timeout() failed them with
  ETIMEDOUT, and fail_while_owned() reports every error but ENODATA and
  ECANCELED to the client's VM status: the client lost its device for
  work it never got to run. They now fail with ENODATA.

- That witness needs every entry to hold a ticket. A spared queue with
  an entry without one would never get it, and the entry's own watchdog
  does nothing for an unpublished packet. quarantine() now fails such
  spared work at once with ENODATA, and timeout_head() reports a queue
  whose spared work still waits, ticketed or not, so that other VMs'
  watchdogs see it as a failed blocker and defer to it within their
  bounded renewals rather than fail their own work immediately.

- Render groups parked for replay after a recovery that do not publish
  within 10 s made replay_render_front() return ETIMEDOUT. That failed
  the GPU event service, which loses every client's device and stops
  the runtime. The parked groups are innocent work that never ran
  (replay::decide() parks only those), so they now fail with ENODATA
  like fail_dropped()'s, spared, and the replay pass goes on.

Bounds: no new timeout. A spared compute fence is settled by its job's
scheduler watchdog, TIMEOUT_MS (2 s) per expiry; on M5 a job is renewed
only while it waits behind an older node of its own context and the
firmware's progress word moves (at most 14 renewals), plus 4 for ray
traversal and AS builds, so 2 s typically and 38 s at most after the
quarantine. A parked render replay keeps its 10 s bound
(background_stalled(), the same as the recovery handshake's wait).

Signed-off-by: Rolando Trevino <rolotrealanis@gmail.com>
Assisted-by: LLM Claude
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 8, 2026 •

Copy link
Copy Markdown
Codex Review Summary: ✅ Completed

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-08T15:56:43.944123Z 9c1cc06 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9c1cc06ccc

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

packet.completion.classify(recovery::Class::Spared);
}
}
self.fail_replay(ENODATA, defer)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Discard a stalled replay's prepared descriptor before signalling

When a replay has been prepared but publish_render() returns early with EBUSY—for example, because the outer work ring is full at runtime/render/submit.rs:679-682—self.prepared still owns the front packet. This new timeout path calls fail_replay(), which empties only self.replay, leaving the prepared descriptor installed after signalling the packet's fence. Consequently, room() and direct_ready() remain false, and firmware_drained() also rejects the pair, leaving that slot unusable and unreclaimable even after backpressure clears. Discard the matching unpublished preparation before completing the stalled replay.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, and it went further than the timeout path. When publish_render() refused a replay before publishing it
(outer ring full, owner switch still releasing, recovery pending), replay_render_front() returned without dropping
the preparation. The next pass's prepare() then failed room() on that same preparation, so a replay refused once
could never be prepared again and always ran into the 10 s bound, and fail_stalled_replay() then left the descriptor
installed. Fixed in the follow-up commit on this branch (kept separate for review, squashed on request). replay_render_front() now discards an unpublished preparation whenever
publish_render() refuses it (runtime/render/replay.rs), so the next pass prepares the group again. fail_replay()
also discards a preparation it finds for the replay front before it fails the parked groups (queue/render.rs), so
no failure path leaves the pair unable to take work. The commit message carries the explanation.

Comment on lines +1162 to +1163
if completion.spared() || completion.guilty() {
continue;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Let a replay's own compute fault override its spared class

After a command newly spared by this method is replayed, its completion retains Class::Spared, but publish_with_native_waits() constructs the replacement Active with spared_as_dropped = false. If the replay then receives Event::ComputeFault and a zero-duration record without another recovery, note_fault() skips reclassification because that flag is false, and this continue preserves the spared class despite the explicit fault report. quarantine(ENODATA) therefore suppresses the actual fault's VM failure and can replay the faulty command again. Preserve the dropped-work provenance across republication, or allow the replay's own fault to override its previous spared class.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. The spared class lives on the completion, but spared_as_dropped lived on the Active, and
publish_with_native_waits() reset it for the replay. A fault report on the replay therefore never reached it, and
classify_unexecuted() kept the old Spared. Fixed in the follow-up commit on this branch (kept separate for review, squashed on request): the replay's Active now starts with
spared_as_dropped = packet.completion.spared(). A replay runs again from the start, so note_fault() now
reclassifies a faulting replay as Guilty and fails its VM, whether the report arrives before or after the
zero-duration record. If it arrives first, classify_unexecuted() skips the now guilty command and quarantine()
returns its VM status. If it arrives after, note_fault() clears spared_quarantine and queues the terminal, as it
does for first-run dropped work. The commit message carries the explanation.

Follow-up to the automated review of this pull request, kept as a separate
commit for review and squashed on request.

drm/asahi: g17: Fix fences of work a GPU recovery dropped failing as a lost device or never signalling
A fence on a scanout buffer that never signals wedges KMS: the atomic
commit worker waits for it with no timeout, so no frame reaches the
display again, even across compositor restarts. Work a recovery dropped
must signal within a bound, and since it never ran, it must not cost
its client the device either. Three paths did not do that:
- A compute queue whose commands a recovery dropped waits, spared, for
  its retirement witness before it replays or fails them. When the
  queue's job watchdog expires first, timeout() failed them with
  ETIMEDOUT, and fail_while_owned() reports every error but ENODATA and
  ECANCELED to the client's VM status: the client lost its device for
  work it never got to run. They now fail with ENODATA.
- That witness needs every entry to hold a ticket. A spared queue with
  an entry without one would never get it, and the entry's own watchdog
  does nothing for an unpublished packet. quarantine() now fails such
  spared work at once with ENODATA, and timeout_head() reports a queue
  whose spared work still waits, ticketed or not, so that other VMs'
  watchdogs see it as a failed blocker and defer to it within their
  bounded renewals rather than fail their own work immediately.
- Render groups parked for replay after a recovery that do not publish
  within 10 s made replay_render_front() return ETIMEDOUT. That failed
  the GPU event service, which loses every client's device and stops
  the runtime. The parked groups are innocent work that never ran
  (replay::decide() parks only those), so they now fail with ENODATA
  like fail_dropped()'s, spared, and the replay pass goes on.
  A replay that publish_render() refused before publishing it (outer
  work ring full, an owner switch still releasing, a recovery pending)
  kept its prepared descriptor, so the next pass could not prepare it
  again and the replay always ran into that bound; failing it then left
  the descriptor installed, and the pair took no work again. Such a
  descriptor is now discarded when the publication is refused, so the
  next pass prepares the group again, and fail_replay() discards one it
  finds for the front.
Bounds: no new timeout. A spared compute fence is settled by its job's
scheduler watchdog, TIMEOUT_MS (2 s) per expiry; on M5 a job is renewed
only while it waits behind an older node of its own context and the
firmware's progress word moves (at most 14 renewals), plus 4 for ray
traversal and AS builds, so 2 s typically and 38 s at most after the
quarantine. A parked render replay keeps its 10 s bound
(background_stalled(), the same as the recovery handshake's wait).
drm/asahi: g17: Fix a GPU recovery failing the compositor for compute work it dropped
When a GPU recovery cannot name the culprit (on M5 every firmware
restart is attributed to the firmware, and classify() gives up when a
guilty queue has no in-flight owner), it classifies nobody. Compute
commands it dropped before they ran then come back from their queue's
records as unexecuted (ENODATA), unclassified, and poisons() is true for
unclassified ENODATA: the queue's terminal records the error and fails
every queue of the VM, and the client's driver reads the VM status as a
lost device. A Chromium GPU fault took Hyprland down this way: right
after the faulting client's EIO, the log reported "GPU work of a client
failed (ENODATA, ...)" for Hyprland too, and zink lost its device.
The render side already handles this: groups a restart dropped are
classified Spared before they fail with ENODATA. Do the same for
compute:
- When a compute queue's front comes back unexecuted after crossing a
  recovery, and the firmware reported no fault on the queue, classify
  every unclassified command of the queue Spared before quarantining it.
  All of them are ordered behind the front, so none of them ran. The
  queue then takes the existing spared path: its commands replay when
  their records prove they never ran, and otherwise fail with ENODATA

Compile-checked with W=1 CLIPPY=1 over drivers/gpu/drm/{asahi,apple}: no new
warnings against the base; checkpatch --strict clean; rustfmt unchanged.

Signed-off-by: Rolando Trevino <rolotrealanis@gmail.com>
Assisted-by: LLM Claude
RyanTheTide added a commit that referenced this pull request Nov 9, 2026
@RyanTheTide
RyanTheTide merged commit ca9f54d into aurora-silicon:integration/aurora Nov 9, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants