From f9bb47ec634b629b8a49c190a7fb5ec6f044230e Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 5 Sep 2026 19:49:22 +0200 Subject: [PATCH] feat(diagnostics): retain bounded local crash reports (#477) Project the original Run failure with loaded build, cached GPU and cell context before unwind. Preserve crash status and rethrow; contain report failures and omit arbitrary messages, source paths and session data. Focused 19/19, narrow production/privacy review and default Release 17044/0/0 pass. Preserve the wrong-package smoke failure and real emitted report; corrected recipe10 smoke exits gracefully with two provisional PNG passes. AMD initiating cause and G4 remain open; FPS deferred. No new retail behavior deviation. --- docs/ISSUES.md | 11 + docs/architecture/acdream-architecture.md | 11 + ...-09-01-campaign-overhaul-world-solidity.md | 12 + .../477-local-crash-report-contract.md | 124 ++++++++ .../477-local-crash-report-verification.md | 195 ++++++++++++ .../s5-consumers-material-closeout-packet.md | 29 ++ .../Diagnostics/LocalCrashReportWriter.cs | 199 +++++++++++++ src/AcDream.App/Rendering/GameWindow.cs | 34 +++ .../LocalCrashReportWriterTests.cs | 278 ++++++++++++++++++ .../Rendering/GameWindowCrashStatusTests.cs | 36 ++- 10 files changed, 927 insertions(+), 2 deletions(-) create mode 100644 docs/research/2026-09-01-overhaul/477-local-crash-report-contract.md create mode 100644 docs/research/2026-09-01-overhaul/477-local-crash-report-verification.md create mode 100644 src/AcDream.App/Diagnostics/LocalCrashReportWriter.cs create mode 100644 tests/AcDream.App.Tests/Diagnostics/LocalCrashReportWriterTests.cs diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 64bd0a3d..df40e22c 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -182,6 +182,17 @@ no new Windows GPU/crash events. Both clients closed. This corrects error propagation, not the initiating AMD fault; #477/G4 remain open, FPS deferred. Windows denied access to the named watchdog dump; no ACL changes attempted. +Owner-requested automatic local crash reporting is implemented and verified: +`research/2026-09-01-overhaul/477-local-crash-report-contract.md` and its +verification note. It joins the original Vulkan error with loaded build, +cached GPU/driver and cell/local position, without new GPU calls or uploads. +The old WER report confirms a timeout involving amdkmdag.sys, not its cause. +Focused19/19, narrow production/privacy review and default full Release17044/0/0 +PASS. An accidental old-package launch saved a real minimal report before +original-error propagation; corrected recipe10 smoke exit0/graceful, two PNGs +inspected narrow PASS/provisional. Both clients closed. This adds future +evidence, not a repair claim for the initiating AMD fault; #477 stays open. + ## #476 — First post-resize graphical gate PNG is horizontally corrupted **Status:** IN-PROGRESS — capture and resize-boundary repairs integrated, diff --git a/docs/architecture/acdream-architecture.md b/docs/architecture/acdream-architecture.md index 1e894e51..bddd3642 100644 --- a/docs/architecture/acdream-architecture.md +++ b/docs/architecture/acdream-architecture.md @@ -1112,6 +1112,17 @@ unsupported noncoherent allocation before wait/read (AD-121); it does not change shared heap selection or generic buffer invalidation. There is one UI stack: the retained tree. +Failures escaping `GameWindow.Run` retain the existing crash latch and bare +rethrow. Before unwind, the App-only `LocalCrashReportWriter` makes one +best-effort bounded JSON report in the canonical diagnostics directory, using +loaded assembly identity, original exception method/type and Vulkan result, +cached GPU capabilities and current controller cell/local coordinates. It +does not query the GPU, serialize session/chat data, upload, run per frame or +recover the device. Reporting failures cannot replace the original exception; +launcher stderr capture and crashed/graceful status remain separate unchanged +owners. Native hard termination and failures before the Run boundary are not +covered by this report. + --- ## Render Pipeline (SSOT — current implementation state; OVERHAUL active) diff --git a/docs/plans/2026-09-01-campaign-overhaul-world-solidity.md b/docs/plans/2026-09-01-campaign-overhaul-world-solidity.md index 78af6da0..69bc7f41 100644 --- a/docs/plans/2026-09-01-campaign-overhaul-world-solidity.md +++ b/docs/plans/2026-09-01-campaign-overhaul-world-solidity.md @@ -927,6 +927,18 @@ cheap-reject equality as inclusive, unlike current strict comparisons; AP-159 remainder3 records this distinct existing boundary tie in the same commit. No occurrence in the installed ramp has yet been measured. +**Owner-requested crash report (packet§65), verified:** a bounded +local report at the existing GameWindow.Run failure catch is implemented +under `477-local-crash-report-contract.md`. Original error/build/cached GPU/ +cell position only, no new GPU call or exception recovery. Old Windows141 +events identify an AMD-module timeout, not its cause; dump/archive access is +denied. Focused19/19, narrow review and default Release17044/0/0 PASS. A real +old-package startup error produced a valid report; corrected recipe10 smoke +exit0/graceful, two PNGs narrow PASS/provisional. Clients closed, ACE up. +Exact evidence/limits in `477-local-crash-report-verification.md`. C1a's code-ready +test-only scratch remains held/unbuilt/unrun; no execution release, geometry +repair expansion, FPS work or G4 acceptance follows from this reporting chunk. + ## 14. Risk register (condensed) - **R1 second scene graph.** Graph records own only topology/membership facts; diff --git a/docs/research/2026-09-01-overhaul/477-local-crash-report-contract.md b/docs/research/2026-09-01-overhaul/477-local-crash-report-contract.md new file mode 100644 index 00000000..8f4f38b7 --- /dev/null +++ b/docs/research/2026-09-01-overhaul/477-local-crash-report-contract.md @@ -0,0 +1,124 @@ +# #477 — small automatic local crash report + +2026-09-05. Base `9baef7c50`. Owner requested a simple report and inspection +of AMD logs so a future recurrence leaves actionable evidence. This chunk +adds diagnostics only; it does not repair or explain the AMD timeout. +FPS remains deferred, the known retail cathedral leak remains unchanged, +and G4 is not accepted. + +## Verified boundary and existing owners + +The original 16:41 Windows WER report is LiveKernelEvent 141 naming +`amdkmdag.sys`. The readable .NET event retained the old candidate's +`vkQueueSubmit2 (abandoned frame timeline signal)` / ErrorDeviceLost stack. +Two WER events share report ID `b3e69f70-71fc-418c-b9e8-686a6bdaf552`: +they are not proof of two separate resets. The detailed WATCHDOG dump and +the exact WER archive folder deny access, including elevated read attempts. +Scoped reads of the observed AMD CN/GameReport/Temp and Radeonsoftware +folders found no readable report explaining this crash. Do not change ACLs, +TDR/driver settings, install software, upload reports, or induce a GPU reset. + +Microsoft describes 0x141 as a display-engine timeout live dump: +[0x141 documentation](https://learn.microsoft.com/en-us/windows-hardware/drivers/debugger/bug-check-0x141---video-engine-timeout-detected). +That is a symptom classification, not proof that AMD software rather than +client GPU work initiated it. + +`GameWindow.Run` already catches the frame-loop failure, retains construction +cleanup, latches `_runFailure` and rethrows. Preserve that sequence and the +existing `ReportExited` crashed/graceful/shutdown-incomplete vocabulary. Add +one report attempt after the latch and before rethrow/teardown. Do not move +reporting into `ReportExited`, when world/graphics owners may be cleared. +The launcher already captures bounded stderr; leave that pipeline intact. + +Use canonical `ApplicationPathSet.DiagnosticsDirectory` (Windows default +`%LOCALAPPDATA%/acdream/cache/diagnostics`) and cached Vulkan capabilities. +This is host-specific observability, not an AC algorithm or a retail port. +There is no retail decomp/binary parity claim or new gameplay divergence. +Lead records this register decision with verification in the landing commit. + +## Allowed scope + +Implementer owns only: + +- `src/AcDream.App/Diagnostics/LocalCrashReportWriter.cs` (new focused owner + and bounded DTOs; no generic diagnostic framework). +- A narrow wiring hook in `src/AcDream.App/Rendering/GameWindow.cs`. +- `tests/AcDream.App.Tests/Diagnostics/LocalCrashReportWriterTests.cs` (new). +- `tests/AcDream.App.Tests/Rendering/GameWindowCrashStatusTests.cs`. + +Lead owns this contract, architecture/issue/campaign ledgers, verification +and commit. OpenAI implementer and independent OpenAI production/privacy +review; no Claude CLI or Anthropic models. No commits or client launches +by implementer. No changes to Core, Runtime, shaders, startup options, +GPU recovery, exception types, status writers or launcher capture. + +## Report contract + +One automatic best-effort local JSON report per failing window run, with +unique UTC/PID/random identity, a unique same-directory temporary file and +atomic non-overwriting rename. No report on a successful run; no per-frame +work, background collector, network/upload, retry or retention deletion. +Existing reports must remain intact. Cap the serialized report at 256 KiB; +bound strings and exception traversal before serialization (at most eight +exception nodes, explicit truncation markers). Preserve valid JSON rather +than truncating raw bytes. Temporary-file cleanup is scoped to this writer's +own exact path and is also best effort. + +Include only explicitly projected data: + +- UTC time, PID, schema version; loaded App assembly version/informational + version and module MVID. Do not infer a clean source revision from git or + read the current checkout as runtime evidence. +- Original exception type, HResult, bounded stack without source file paths, + and bounded inner/aggregate structure. Exact `VulkanCallException.Operation` + plus numeric and named `Result` when present. Arbitrary exception messages, + `Data`, object serialization, raw environment/arguments/session config, + character/account names, credentials, chat and packet contents are omitted. + The original unhandled error/stderr behavior remains available unchanged. +- Cached GPU device name, driver/API information, actual width/height and + sample count from the existing Vulkan context; no new GPU/native queries. +- Capture the current controller reference once and project its existing + `CellPosition` cell/local XYZ and `State`; optional Runtime generation. + Unavailable values remain null, not invented cell/position zero. Non-finite + coordinates must not prevent the original exception from propagating. + +Capture, serialization, file creation/write/rename, cleanup and any diagnostic +notification must never replace the original failure. Guard the entire hook, +including context/delegate construction. Keep bare `throw;` and crash status. +Use existing output only for a short saved-path notice or safe failure notice; +never log the secondary reporting exception or serialize its arbitrary data. +An unavailable context should still allow a minimal error report where possible. + +Coverage is failures escaping the existing window loop. Do not claim this +catches native process termination, stack overflow, power loss, forced kill, +startup before that boundary, or an unrecovered GPU hang. Do not claim a +report identifies the driver's initiating fault or fixes a crash. + +## Verification / stop rules + +No build or test beside either graphical client. Lead rechecked both clients +and debugger absent and ACE UDP9000 PID13340 present before this contract. +Implementer must recheck before running Release build/focused tests. + +Tests must exercise actual writer output: exact Vulkan fields and nested +aggregate handling; absent world/GPU; non-finite coordinates; Unicode/large +input/node/byte bounds; no message/Data/source-path leakage; unique reports +and no overwrite; no final partial JSON/temp leftovers on normal success; +failed context capture, inaccessible/invalid write destination and failing +notification do not throw or change the original exception. Use isolated +temporary directories, never the user's actual diagnostics directory. + +Extend compiled-call coverage to prove the real `Run` catch latches, reports, +then rethrows, retaining existing crash-status tests. Lead independently checks +production inputs and failure safety, and runs focused and integrated Release +lanes. Independent review is limited to production wiring, correctness, +privacy and failure containment; no new full renderer review. + +No deliberate graphical crash or driver reset. A short ordinary graphical +smoke may follow green Release/review to prove unchanged startup/world/close; +record exact binary and PNG verdict provisionally and close gracefully. Writer +behavior is proved by controlled exceptions in tests, not by harming the GPU. + +Stop on a required extra production file, contract ambiguity, a second red +build, or a third fix round. Preserve raw failures and return bounded findings +to the lead; do not weaken assertions or widen scope to force a PASS. diff --git a/docs/research/2026-09-01-overhaul/477-local-crash-report-verification.md b/docs/research/2026-09-01-overhaul/477-local-crash-report-verification.md new file mode 100644 index 00000000..ec7e2c5c --- /dev/null +++ b/docs/research/2026-09-01-overhaul/477-local-crash-report-verification.md @@ -0,0 +1,195 @@ +# #477 — automatic local report / Windows evidence + +2026-09-05. **Reporting PASS; focused19/19, narrow review and integrated17044/0/0 +PASS. Corrected ordinary smoke PASS/provisional, exit0/graceful; clients closed.** +Bounded contract: `477-local-crash-report-contract.md`; source base `9baef7c50`. +This is an observability change, not a claim that AMD device loss is fixed. + +## What the old logs establish + +Lead re-read the exact local Application event window 16:41–16:42 +02: + +| Local time | Event / record | Observation | +|---|---|---| +| 16:41:20.1277754 | .NET Runtime 1026 / 713399 | AcDream.App unhandled VulkanCallException; `vkQueueSubmit2 (abandoned frame timeline signal)` / ErrorDeviceLost. | +| 16:41:28.4400641 | WER 1001 / 713400 | LiveKernelEvent141, initially empty bucket. | +| 16:41:57.2733346 | WER 1001 / 713401 | Same report, bucket `LKD_0x141_Tdr:C_AppFault_IMAGE_amdkmdag.sys-PF:1-HWS:1-SCH-PS:95d63b9d-CS:6708cc79-GS:62c333ab`. | + +Both WER records name report `b3e69f70-71fc-418c-b9e8-686a6bdaf552` and the +same five problem-signature values; do not count them as two independent +resets. Exact candidate/process context remains in `477-cathedral-recurrence.md`. +This predates first-native-failure preservation at `f4da814af`. + +0x141 identifies a display engine failing to respond in time, not a proven +client/driver initiating cause. [Microsoft reference](https://learn.microsoft.com/en-us/windows-hardware/drivers/debugger/bug-check-0x141---video-engine-timeout-detected). +The event names `C:/WINDOWS/LiveKernelReports/WATCHDOG/WATCHDOG-20260905-1641.dmp`. +Both that dump and exact WER archive folder +`C:/ProgramData/Microsoft/Windows/WER/ReportArchive/Kernel_141_475b65fe8bdf2cfdc1be595d354ddee35b4fd0dd_00000000_cab_b3e69f70-71fc-418c-b9e8-686a6bdaf552` +deny read access even through elevated tool calls. They were not analyzed; +no ACL, ownership, driver or TDR change was attempted. + +Scoped AMD checks found `C:/Users/erikn/AppData/Local/AMD/CN` and +`Radeonsoftware`. CN's September4 RSX_Common log is empty; its other observed +log is from August. CN/GameReport has one observed file, none dated the crash +day; CN/Temp is empty; Radeonsoftware's observed cache/images are older. +No relevant readable AMD cause report was found in those bounded checks. +This is not a claim that no other report exists anywhere on the machine. +No settings/database contents, arbitrary shader caches, or remote submissions +were needed. Future app reporting remains independent of AMD tooling. + +## Existing seams independently checked by the lead + +- Run's existing exception catch latches before rethrow; ReportExited already + distinguishes crashed, graceful and shutdown-incomplete. Constructor/startup + before the Run boundary and native hard failures remain outside this chunk. +- Canonical ApplicationPathSet owns diagnostics paths; the launcher already + drains bounded stderr. No new directory resolver or log subsystem is needed. +- Vulkan Capabilities is a stored managed record; width/height read the stored + swapchain configuration and SampleCount is cached. No native call is required. +- PlayerMovementController.CellPosition borrows the physics body's current + cell/local origin. It is not the camera/render position; its orientation is + not a current body-facing witness and is deliberately not projected here. +- No AC-specific algorithm, rendering decision, membership, collision, wire, + teardown or exception-recovery policy is being changed. Register disposition: + no new retail deviation; no row/count churn for host-only local diagnostics. + +## Frozen implementation and focused proof + +Two production files only: the focused App writer and a 34-line Run hook/ +projection in GameWindow. Report construction caps eight exception nodes, +bounded method metadata strings and a final256KiB serialized payload, with +explicit truncation/non-finite/unavailable markers. Bare original rethrow and +the existing crash-status vocabulary are unchanged. The root separately +checked the actual borrowed Vulkan context's publication/getters and controller +position source. No new native call or normal-frame work was introduced. + +Final source SHA256: + +| File | SHA256 | +|---|---| +| App/Diagnostics/LocalCrashReportWriter.cs | `5EF0E66C48D1376A50694278C792CB6587A94333233AFF9BA5BC8012E87F5440` | +| App/Rendering/GameWindow.cs | `2AA765C5F2566CC81423E56364DD95B0B8C8B705D8E7A4E26A1C07AC2133BB48` | +| App.Tests/Diagnostics/LocalCrashReportWriterTests.cs | `829D595DDEB9DAB6796C653367BF65AD12E4C801A6FD6FCFB4626C6D9775EC5A` | +| App.Tests/Rendering/GameWindowCrashStatusTests.cs | `45AF6B9CF2D6332B88A8909B5A8AA3A54A15B4D631C305BD0148FD67AC50F5A1` | + +Evidence under `artifacts/477-local-crash-report/`, independently read/hashed +by the lead: + +- `release-build-1.log`: FAIL, two CS0619 errors in new tests. Always-throwing + lambdas selected xUnit's obsolete Func overload. This was not a + production compile or runtime failure. SHA256 + `FB134920D8D062F112766F9FD8F1ECEFF8B396F956150C97EBB0781EDA219CD5`. +- Fix round1 adds explicit synchronous Action casts at those two tests; + production and assertions unchanged. `release-build-2.log`: PASS,0W0E. + SHA256 `3436CC7D5F4461A8A5D3A5D6177EF9EC13233E2E1B86C1D352C51201D138025B`. +- `focused-1.trx`:19executed/19passed/0failed/0skipped. SHA256 + `9883E8BE70121AAF189252105F64C6DEF937EE753AB4AD6DC2C36468B7FEAF54`. + +Tests write/parse real isolated JSON files, retain exact Vulkan fields and +loaded assembly identity, and cover absent/failed context, non-finite positions, +nested/wide/deep exception trees, Unicode limits, non-overwriting output, +blocked/invalid paths, failed notification and original-exception identity. +Privacy cases include injected remote-stack text and poisoned virtual +Message/StackTrace/Data/ToString accessors. Production IL assertions prove the +real catch's latch/report/guard/rethrow ordering and single cached-owner reads. +Temporary test directories are removed by the tests; no fake report was put +in the user's diagnostics folder and no deliberate graphical crash was induced. + +Independent OpenAI production/privacy review found no issues. Narrow closure +independently checked the two Action casts, original failure, green build, +19/19 TRX and final hashes; PASS, no further fix round. No production change +followed review. Lead separately parsed the same evidence and verified scope. + +## Lead integrated Release + +`artifacts/477-local-crash-integrated-20260905/`, exec86511 completed exit0. +Locked restore and solution Release build0W0E;14test assemblies, +17044executed/passed,0failed/0skipped. All19report/wiring tests are present +and PASS in the full App TRX. Lead rehashed32/32manifest entries. +Summary SHA256 `271261E59D04D53646CA4191DC0DFD2C22999C545D4D10ECA5FC5E673BC802EB`. + +Exact source identity is parent9baef7c50782069f19d7a55d74712559979debc3, +WorktreeDirty=true with the frozen four-file patch and lead docs. This lane +uses the repository's documented DEFAULT filter, not the previous chunk's +broader custom filter: it also excludes Windows/Vulkan/SystemFont lanes, +Purpose=Diagnostic and Status=KnownFailure. Thus its17044count is not directly +comparable to the previous17051headline; tests were not removed or weakened. +The precise filter and all per-assembly totals remain in the summary. + +Exact Release DLL SHA256 for both graphical attempts below: + +- App `4CE893E4F69A4E67C2D77FA499D551B1603B94E8C8F5E766D66B87BBA149FC8D`. +- Core `4FA128BA1BF1366D3AF6F008289EDCFD07F1D0B94B855321BDA37FEC27C79338`. + +## Ordinary smoke: retain the failed launch, not just the retry + +First attempt `logs/selfgate-20260905-194419-477-local-crash-smoke/`, PID30360: +FAIL before login, zero PNGs, original unhandled InvalidDataException / exit +-532462766. Lead accidentally omitted the explicit recipe10 Session argument, +so the runner's older default selected acdream.pak recipe8; the current +client correctly requires10. This is a launch-configuration error, not an +AMD recurrence or a production reporting bug. No forced close was necessary; +lead confirmed the process absent before retry. Runner script exit0 is NOT +the client's success: the client's nonzero exit above is authoritative. + +The new Run hook actually wrote this report before original-error propagation: +`C:/Users/erikn/AppData/Local/acdream/cache/diagnostics/crash-20260905T1744207523446Z-30360-29bc68a5d79b42f99f9bf651a7240df4.json`. +Lead opened and parsed it: original InvalidDataException type/HResult/method +stack, PID30360, loaded informational version1.0.0+9baef7c5..., MVID +cead7ceb-6a91-4b92-9199-e21d3ea354ea, cached RX9070XT/driver2.0.395/ +Vulkan1.4.349/1280x720/4x; world null because login never occurred, Vulkan +failure fields null because this was not a Vulkan error. No arbitrary message, +package/source path or session data is serialized. Report SHA256 +`B55868781EB8A19719EC3FD94177F21F7E7539521C55F83FEAF60EA5935FB3F0`. +The original detailed error remains in stderr. This supplies an unplanned +real Run-boundary reporting witness, not a GPU-crash reproduction. + +Retry `logs/selfgate-20260905-194522-477-local-crash-smoke-v10/`, exec92566, +uses the exact prior successful `logs/s5-c5-session-v10.json` / existing +acdream-v10-s5-c5.pak. Source and binaries unchanged, no rebuild. Clients/ +debugger absent and ACE9000 PID13340 up before each launch. Existing +`route-482-controls.txt` SHA256 +`21506C621979A43A604F973C31149B9D192C66A7965782315ADD3B7011CA3AF5`; +two settled cathedral/Holtburg checkpoints/PNGs followed by graceful close. +Completed exit0/gracefully; script complete plus logout requested/confirmed. +At19:47+02 both clients/debugger absent; ACE9000 PID13340 remained up. Both +checkpoints are completed/visible, wait cue=false, invariant failures=0. No +device-loss/unhandled/script-failure/timeout/report notification matched the +corrected run's stdout/stderr. System/Application events19:45:22–19:47:00 +contain zero matching GPU/crash reports. This short smoke is not another +stability clearance or a full lifecycle/reconnect gate. + +Lead opened both PNGs under the corrected run's `artifacts/screenshots/`: + +| PNG | Narrow verdict / limitation | SHA256 | +|---|---|---| +| `482-control-cathedral-settled.png` | PASS/provisional: visible textured floor, arch/walls, player and retained UI; no tunnel/wait caption. Open exterior views do not establish a fix for the known retail cathedral leak. | `96F76C741FD576D6AA92C7705FF6FA5F9CA66E34D32F0AD222759D26328E6568` | +| `482-control-holtburg-settled.png` | PASS/provisional for ground, house and nearby actor/world reveal. Close-camera player fading is visible; not an avatar-opacity or full-scene acceptance claim. | `AA59410A0192F90C97C0092A7A5EE4C4F583A8B2A44E7C6189BAAE62A5E9B33E` | + +Corrected terminal artifacts SHA256: + +- client.log `42B2418028A9CD7E4EF5ABB335B0E2D76FE90AA3380F0D6AB95983B246880B1B`. +- client.err.log `DDEFD71E602AD4EE3AB410852A39C4140089377A0DD7BEA26E83F6BE13789FDF`. +- run.json `59B643AFAB36A716C2E171CFDC9FE511A29F0CA3A6A741A078DF69DE2276D146`. +- checkpoints JSONL `588EEEF8AB5CAEB85B5D8E5977A809E1E082C1014821B9A9C906956B3ED5823D`. + +The real startup report is2072bytes and is preserved in the user's diagnostics +directory. Its production run includes the original exception's actual +method metadata and no world position fabricated before login. It demonstrates +real hook/file/error propagation in addition to the controlled tests, without +claiming that an actual Vulkan device-loss report has yet been captured by +this newly added reporter. + +## Closeout and limitations + +Controlled exceptions, actual compiled wiring and the retained real startup error establish reporting, +not an induced driver failure. Native hard hangs/termination and errors before Run +are outside coverage. G4, #477 initiating fault and AP159 stay open; FPS and +the known retail cathedral leak remain outside this chunk. + +For a future recurrence, retain `crash-*.json` from the canonical diagnostics +directory and the existing launcher stderr, then correlate the JSON UTC/PID +with Windows Application/System events. The loaded MVID is the binary identity; +the informational-version commit alone is not proof of a clean source tree. +No automatic upload, report deletion, driver or Windows permission change is +part of this feature. Held C1a scratch diagnostic is unchanged/unbuilt/unrun. diff --git a/docs/research/2026-09-01-overhaul/s5-consumers-material-closeout-packet.md b/docs/research/2026-09-01-overhaul/s5-consumers-material-closeout-packet.md index 6e3133f7..52d53856 100644 --- a/docs/research/2026-09-01-overhaul/s5-consumers-material-closeout-packet.md +++ b/docs/research/2026-09-01-overhaul/s5-consumers-material-closeout-packet.md @@ -5160,3 +5160,32 @@ paired indoor branch decoding proves both cheap-reject equalities admit in retail, unlike our strict comparisons. AP-159 remainder3 records this existing tie separately from remainder2; active row count unchanged. No scene occurrence or production change inferred. No new graphical run belongs to C1a. + +## 65. Owner-requested automatic local crash report — 2026-09-05 + +Implemented under `477-local-crash-report-contract.md`, base9baef7c50. +Four scoped App source/test files, OpenAI implementation and independent +review limited to production wiring, correctness, privacy and +failure containment. No new renderer review or AC-specific algorithm change. +One report after Run latches its original failure, before rethrow: loaded +build, original error/Vulkan result, cached GPU/driver and current cell/local +position. No per-frame work, fresh GPU query, upload or recovery. Existing +launcher stderr and status vocabulary stay unchanged. Reporting PASS: +focused19/19, narrow review closed, default Release0W0E/17044pass/0fail/0skip, +manifest32/32. One test-overload compilation correction, no production fixes +after review. This is not an AMD-cause repair claim. + +Lead's exact old Windows checks and access limits are in +`477-local-crash-report-verification.md`: .NET1026/713399 and two WER1001 +records713400/713401, the same report ID, LiveKernelEvent141/amdkmdag.sys. +Dump and WER archive deny read; scoped readable AMD folders do not explain +the cause. No settings, ACL, driver change or submission. Clients/debugger +absent, ACE9000 PID13340 up before implementation; recheck before execution. +The first smoke accidentally used the older default recipe8package and failed +before login; it saved a real2072-byte report then preserved the original +exception/nonzero exit. Evidence is retained, not counted as graphical PASS. +Corrected existing recipe10session smoke exit0/graceful, two PNGs opened narrow +PASS/provisional, no new GPU/crash events; clients/debugger closed, ACE up. +Frozen source/binary/report/PNG hashes and exact default-vs-prior custom test +filter distinction are in the verification note. C1a scratch diagnostic remains +held/unbuilt/unrun. Known retail leak, FPS deferral and G4 status unchanged. diff --git a/src/AcDream.App/Diagnostics/LocalCrashReportWriter.cs b/src/AcDream.App/Diagnostics/LocalCrashReportWriter.cs new file mode 100644 index 00000000..27a1c247 --- /dev/null +++ b/src/AcDream.App/Diagnostics/LocalCrashReportWriter.cs @@ -0,0 +1,199 @@ +using System.Diagnostics; +using System.Reflection; +using System.Text; +using System.Text.Json; +using AcDream.App.Rendering.Gpu.Vk; + +namespace AcDream.App.Diagnostics; + +/// One bounded, local, best-effort report at the existing Run failure boundary. +internal static class LocalCrashReportWriter +{ + internal const int MaxReportBytes = 256 * 1024; + internal const int MaxExceptionNodes = 8; + private const string Truncated = "[truncated]"; + + internal static string? TryWrite( + Exception failure, + string diagnosticsDirectory, + Func? captureContext = null, + Action? notify = null) + { + string? temporaryPath = null; + bool ownsTemporaryFile = false; + try + { + LocalCrashReportContext? context = null; + bool contextCaptureFailed = false; + try { context = captureContext?.Invoke(); } + catch { contextCaptureFailed = true; } + + DateTimeOffset utc = DateTimeOffset.UtcNow; + int processId = Environment.ProcessId; + Assembly assembly = typeof(LocalCrashReportWriter).Assembly; + var exceptions = new List(MaxExceptionNodes); + AddException(failure, null, "root", null, exceptions); + LocalCrashGpu? gpu = context?.Gpu; + LocalCrashWorld? world = context?.World; + var report = new + { + SchemaVersion = 1, + Utc = utc, + ProcessId = processId, + AssemblyVersion = Bound(assembly.GetName().Version?.ToString(), 256), + InformationalVersion = Bound(assembly.GetCustomAttribute()?.InformationalVersion, 512), + ModuleMvid = assembly.ManifestModule.ModuleVersionId, + ContextCaptureFailed = contextCaptureFailed, + Exceptions = exceptions, + ExceptionTreeTruncated = exceptions.Any(node => node.ChildrenTruncated), + Gpu = gpu is null ? null : new LocalCrashGpu( + Bound(gpu.DeviceName, 512), Bound(gpu.DriverInfo, 512), + Bound(gpu.InstanceApiVersion, 128), Bound(gpu.DeviceApiVersion, 128), + gpu.DeviceApiVersionPacked, gpu.Width, gpu.Height, gpu.SampleCount), + World = world is null ? null : new + { + world.CellId, + X = Finite(world.X), Y = Finite(world.Y), Z = Finite(world.Z), + State = Bound(world.State, 128), + NonFiniteCoordinates = IsNonFinite(world.X) || IsNonFinite(world.Y) || IsNonFinite(world.Z), + }, + }; + + // Strings and traversal are bounded before serialization; never cut JSON bytes. + byte[] bytes = JsonSerializer.SerializeToUtf8Bytes(report, new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + }); + if (bytes.Length > MaxReportBytes) + throw new InvalidOperationException(); + + string identity = $"crash-{utc:yyyyMMddTHHmmssfffffffZ}-{processId}-{Guid.NewGuid():N}"; + string finalPath = Path.Combine(diagnosticsDirectory, identity + ".json"); + temporaryPath = Path.Combine(diagnosticsDirectory, "." + identity + $"-{Guid.NewGuid():N}.tmp"); + Directory.CreateDirectory(diagnosticsDirectory); + using (var stream = new FileStream(temporaryPath, FileMode.CreateNew, FileAccess.Write, FileShare.None)) + { + // A failed CreateNew must never give us ownership of somebody else's file. + ownsTemporaryFile = true; + stream.Write(bytes); + stream.Flush(); + } + File.Move(temporaryPath, finalPath, overwrite: false); + ownsTemporaryFile = false; + Notify(notify, $"Local crash report saved: {finalPath}"); + return finalPath; + } + catch + { + Notify(notify, "Local crash report unavailable."); + return null; + } + finally + { + if (ownsTemporaryFile && temporaryPath is not null) + { + try { File.Delete(temporaryPath); } + catch { /* Cleanup cannot replace the original failure. */ } + } + } + } + + private static void AddException( + Exception exception, int? parentIndex, string relation, int? aggregateIndex, + List entries) + { + int index = entries.Count; + var entry = new ExceptionEntry( + parentIndex, relation, aggregateIndex, + Bound(exception.GetType().FullName, 512), exception.HResult, MethodStack(exception), + exception is VulkanCallException vk + ? new VulkanEntry(Bound(vk.Operation, 512), (int)vk.Result, Bound(vk.Result.ToString(), 128)) + : null); + entries.Add(entry); + if (exception is AggregateException aggregate) + { + // InnerException aliases the first aggregate child; do not emit it twice. + for (int i = 0; i < aggregate.InnerExceptions.Count; i++) + { + if (entries.Count == MaxExceptionNodes) + { + entry.ChildrenTruncated = true; + break; + } + AddException(aggregate.InnerExceptions[i], index, "aggregate", i, entries); + } + } + else if (exception.InnerException is { } inner) + { + if (entries.Count == MaxExceptionNodes) + entry.ChildrenTruncated = true; + else + AddException(inner, index, "inner", null, entries); + } + } + + private static string MethodStack(Exception exception) + { + try + { + // Never read Exception.StackTrace/ToString: either can contain injected text, + // remote stack strings or source paths. Project only actual method metadata. + var trace = new StackTrace(exception, fNeedFileInfo: false); + var text = new StringBuilder(); + for (int i = 0; i < trace.FrameCount && i < 48; i++) + { + MethodBase? method = trace.GetFrame(i)?.GetMethod(); + if (method is null) + continue; + if (text.Length != 0) + text.Append('\n'); + text.Append(Bound(method.DeclaringType?.FullName, 256)); + text.Append('.'); + text.Append(Bound(method.Name, 256)); + if (text.Length > 3072) + return Bound(text.ToString(), 3072)!; + } + if (trace.FrameCount > 48) + text.Append(Truncated); + return Bound(text.ToString(), 3072)!; + } + catch { return "[unavailable]"; } + } + + private static string? Bound(string? value, int limit) + { + if (value is null || value.Length <= limit) + return value; + int end = limit - Truncated.Length; + if (end > 0 && char.IsHighSurrogate(value[end - 1]) && char.IsLowSurrogate(value[end])) + end--; + return string.Concat(value.AsSpan(0, end), Truncated); + } + + private static bool IsNonFinite(float? value) => value.HasValue && !float.IsFinite(value.Value); + private static float? Finite(float? value) => IsNonFinite(value) ? null : value; + + private static void Notify(Action? notify, string text) + { + try { notify?.Invoke(text); } + catch { /* Notification is not a second failure channel. */ } + } + + private sealed record VulkanEntry(string? Operation, int Result, string? ResultName); + + private sealed record ExceptionEntry( + int? ParentIndex, string Relation, int? AggregateIndex, string? Type, + int HResult, string Stack, VulkanEntry? Vulkan) + { + public bool ChildrenTruncated { get; set; } + } +} + +internal sealed record LocalCrashReportContext(LocalCrashGpu? Gpu, LocalCrashWorld? World); + +internal sealed record LocalCrashGpu( + string? DeviceName, string? DriverInfo, string? InstanceApiVersion, + string? DeviceApiVersion, uint? DeviceApiVersionPacked, + uint? Width, uint? Height, int? SampleCount); + +internal sealed record LocalCrashWorld(uint? CellId, float? X, float? Y, float? Z, string? State); diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 487ecad7..2c7cebb1 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -907,10 +907,44 @@ public sealed class GameWindow : // CompleteShutdown) can run mid-unwind of this exact exception, // via Program.cs's `using var window = ...`. _runFailure = failure; + try + { + // Guard argument/delegate construction as well as report I/O. Preserve + // the original exception and capture cached owners before teardown. + Diagnostics.LocalCrashReportWriter.TryWrite( + failure, _applicationPaths.DiagnosticsDirectory, + CaptureLocalCrashReportContext, Console.Error.WriteLine); + } + catch { /* Diagnostics must never mask the frame-loop failure. */ } throw; } } + private Diagnostics.LocalCrashReportContext CaptureLocalCrashReportContext() + { + var graphics = _vulkanGraphics; + var capabilities = graphics?.Capabilities; + uint? width = graphics?.Width; + uint? height = graphics?.Height; + var gpu = graphics is null ? null : new Diagnostics.LocalCrashGpu( + capabilities?.DeviceName, capabilities?.DriverInfo, + capabilities?.InstanceApiVersion, capabilities?.DeviceApiVersion, + capabilities?.DeviceApiVersionPacked, + width is > 0 ? width : null, height is > 0 ? height : null, + graphics.SampleCount); + + var controller = _playerController; + Diagnostics.LocalCrashWorld? world = null; + if (controller is not null) + { + var position = controller.CellPosition; + world = new Diagnostics.LocalCrashWorld( + position.ObjCellId, position.Frame.Origin.X, + position.Frame.Origin.Y, position.Frame.Origin.Z, controller.State.ToString()); + } + return new Diagnostics.LocalCrashReportContext(gpu, world); + } + void IGameWindowPlatformPublication.PublishGraphics( GameWindowGraphics graphics) => PublishCompositionOwner(ref _graphics, graphics, "graphics API"); diff --git a/tests/AcDream.App.Tests/Diagnostics/LocalCrashReportWriterTests.cs b/tests/AcDream.App.Tests/Diagnostics/LocalCrashReportWriterTests.cs new file mode 100644 index 00000000..5c882063 --- /dev/null +++ b/tests/AcDream.App.Tests/Diagnostics/LocalCrashReportWriterTests.cs @@ -0,0 +1,278 @@ +using System.Collections; +using System.Reflection; +using System.Runtime.ExceptionServices; +using System.Text.Json; +using AcDream.App.Diagnostics; +using AcDream.App.Rendering.Gpu.Vk; +using Silk.NET.Vulkan; + +namespace AcDream.App.Tests.Diagnostics; + +public sealed class LocalCrashReportWriterTests : IDisposable +{ + private readonly string _directory = Directory.CreateTempSubdirectory("acdream-crash-report-tests-").FullName; + + [Fact] + public void WrittenReport_PreservesExactVulkanFieldsAndLoadedAssemblyIdentity() + { + VulkanCallException failure = ThrownVulkan(); + var notices = new List(); + string path = Assert.IsType(LocalCrashReportWriter.TryWrite(failure, _directory, notify: notices.Add)); + using JsonDocument document = JsonDocument.Parse(File.ReadAllBytes(path)); + JsonElement root = document.RootElement; + JsonElement entry = Assert.Single(root.GetProperty("exceptions").EnumerateArray()); + Assert.Equal(1, root.GetProperty("schemaVersion").GetInt32()); + Assert.Equal(Environment.ProcessId, root.GetProperty("processId").GetInt32()); + Assert.InRange(DateTimeOffset.UtcNow - root.GetProperty("utc").GetDateTimeOffset(), TimeSpan.Zero, TimeSpan.FromMinutes(1)); + Assembly assembly = typeof(LocalCrashReportWriter).Assembly; + Assert.Equal(assembly.GetName().Version!.ToString(), root.GetProperty("assemblyVersion").GetString()); + Assert.Equal(assembly.GetCustomAttribute()!.InformationalVersion, + root.GetProperty("informationalVersion").GetString()); + Assert.Equal(assembly.ManifestModule.ModuleVersionId, root.GetProperty("moduleMvid").GetGuid()); + Assert.Equal(typeof(VulkanCallException).FullName, entry.GetProperty("type").GetString()); + Assert.Equal(failure.HResult, entry.GetProperty("hResult").GetInt32()); + Assert.Contains(nameof(ThrownVulkan), entry.GetProperty("stack").GetString()); + JsonElement vk = entry.GetProperty("vulkan"); + Assert.Equal("vkQueueSubmit2 (abandoned frame timeline signal)", vk.GetProperty("operation").GetString()); + Assert.Equal(-4, vk.GetProperty("result").GetInt32()); + Assert.Equal(nameof(Result.ErrorDeviceLost), vk.GetProperty("resultName").GetString()); + Assert.Equal($"Local crash report saved: {path}", Assert.Single(notices)); + } + + [Fact] + public void NestedAggregate_PreservesParentAndChildOrderWithoutDuplicateFirstChild() + { + var failure = new AggregateException("omitted", + new InvalidOperationException("omitted", new ArgumentException("omitted")), + new AggregateException("omitted", new VulkanCallException("vkAcquireNextImageKHR", Result.ErrorDeviceLost))); + using JsonDocument document = Write(failure); + JsonElement[] nodes = document.RootElement.GetProperty("exceptions").EnumerateArray().ToArray(); + Assert.Equal(5, nodes.Length); + Assert.Equal(new[] { "root", "aggregate", "inner", "aggregate", "aggregate" }, + nodes.Select(node => node.GetProperty("relation").GetString())); + Assert.Equal(new int?[] { null, 0, 1, 0, 3 }, + nodes.Select(node => NullableInt(node.GetProperty("parentIndex")))); + Assert.Equal(new int?[] { null, 0, null, 1, 0 }, + nodes.Select(node => NullableInt(node.GetProperty("aggregateIndex")))); + Assert.False(document.RootElement.GetProperty("exceptionTreeTruncated").GetBoolean()); + Assert.Equal("vkAcquireNextImageKHR", nodes[4].GetProperty("vulkan").GetProperty("operation").GetString()); + } + + [Fact] + public void MissingContext_LeavesGpuAndWorldNull() + { + using JsonDocument document = Write(new Exception("private")); + Assert.Equal(JsonValueKind.Null, document.RootElement.GetProperty("gpu").ValueKind); + Assert.Equal(JsonValueKind.Null, document.RootElement.GetProperty("world").ValueKind); + Assert.False(document.RootElement.GetProperty("contextCaptureFailed").GetBoolean()); + } + + [Fact] + public void Context_PreservesOnlyProjectedGpuAndCellLocalPosition() + { + var gpu = new LocalCrashGpu("AMD device", "cached driver", "1.3", "1.4", 123, 1920, 1080, 4); + var world = new LocalCrashWorld(0xF4180111, 12.25f, -3.5f, 160, "Falling"); + using JsonDocument document = Write(new Exception(), () => new(gpu, world)); + JsonElement actualGpu = document.RootElement.GetProperty("gpu"); + Assert.Equal("AMD device", actualGpu.GetProperty("deviceName").GetString()); + Assert.Equal("cached driver", actualGpu.GetProperty("driverInfo").GetString()); + Assert.Equal("1.3", actualGpu.GetProperty("instanceApiVersion").GetString()); + Assert.Equal("1.4", actualGpu.GetProperty("deviceApiVersion").GetString()); + Assert.Equal(123u, actualGpu.GetProperty("deviceApiVersionPacked").GetUInt32()); + Assert.Equal(1920, actualGpu.GetProperty("width").GetInt32()); + Assert.Equal(1080, actualGpu.GetProperty("height").GetInt32()); + Assert.Equal(4, actualGpu.GetProperty("sampleCount").GetInt32()); + JsonElement actualWorld = document.RootElement.GetProperty("world"); + Assert.Equal(0xF4180111u, actualWorld.GetProperty("cellId").GetUInt32()); + Assert.Equal(12.25f, actualWorld.GetProperty("x").GetSingle()); + Assert.Equal(-3.5f, actualWorld.GetProperty("y").GetSingle()); + Assert.Equal(160f, actualWorld.GetProperty("z").GetSingle()); + Assert.Equal("Falling", actualWorld.GetProperty("state").GetString()); + Assert.False(actualWorld.GetProperty("nonFiniteCoordinates").GetBoolean()); + } + + [Fact] + public void NonFiniteCoordinates_AreExplicitlyNullAndDoNotPreventAReport() + { + using JsonDocument document = Write(new Exception(), () => new(null, + new LocalCrashWorld(null, float.NaN, float.PositiveInfinity, float.NegativeInfinity, null))); + JsonElement world = document.RootElement.GetProperty("world"); + foreach (string name in new[] { "cellId", "x", "y", "z", "state" }) + Assert.Equal(JsonValueKind.Null, world.GetProperty(name).ValueKind); + Assert.True(world.GetProperty("nonFiniteCoordinates").GetBoolean()); + } + + [Fact] + public void LargeUnicodeAndWideExceptionTree_AreBoundedBeforeValidJsonIsWritten() + { + string huge = string.Concat(Enumerable.Repeat("秘密🌋", 20_000)); + var failure = new AggregateException(Enumerable.Range(0, 1_000) + .Select(_ => new VulkanCallException(huge, Result.ErrorDeviceLost))); + var gpu = new LocalCrashGpu(huge, huge, huge, huge, null, null, null, null); + string path = Assert.IsType(LocalCrashReportWriter.TryWrite(failure, _directory, () => new(gpu, new(null, null, null, null, huge)))); + byte[] bytes = File.ReadAllBytes(path); + Assert.InRange(bytes.Length, 1, LocalCrashReportWriter.MaxReportBytes); + using JsonDocument document = JsonDocument.Parse(bytes); + JsonElement root = document.RootElement; + Assert.Equal(LocalCrashReportWriter.MaxExceptionNodes, root.GetProperty("exceptions").GetArrayLength()); + Assert.True(root.GetProperty("exceptionTreeTruncated").GetBoolean()); + Assert.True(root.GetProperty("exceptions")[0].GetProperty("childrenTruncated").GetBoolean()); + Assert.EndsWith("[truncated]", root.GetProperty("gpu").GetProperty("deviceName").GetString()); + Assert.InRange(root.GetProperty("gpu").GetProperty("deviceName").GetString()!.Length, 1, 512); + Assert.EndsWith("[truncated]", root.GetProperty("exceptions")[1].GetProperty("vulkan").GetProperty("operation").GetString()); + Assert.DoesNotContain("\uFFFD", document.RootElement.ToString()); + } + + [Fact] + public void DeepInnerTree_StopsAtEightAndMarksOmittedChildren() + { + Exception failure = new Exception(); + for (int i = 0; i < 100; i++) + failure = new Exception("not serialized", failure); + using JsonDocument document = Write(failure); + JsonElement nodes = document.RootElement.GetProperty("exceptions"); + Assert.Equal(LocalCrashReportWriter.MaxExceptionNodes, nodes.GetArrayLength()); + Assert.True(nodes[7].GetProperty("childrenTruncated").GetBoolean()); + Assert.True(document.RootElement.GetProperty("exceptionTreeTruncated").GetBoolean()); + } + + [Fact] + public void MessagesDataSourcePathsAndInjectedRemoteStack_AreNeverSerialized() + { + var failure = new Exception("PRIVATE_MESSAGE C:\\secret\\source.cs"); + failure.Data["PRIVATE_DATA"] = "secret session"; + ExceptionDispatchInfo.SetRemoteStackTrace(failure, "PRIVATE_REMOTE_STACK at Fake in C:\\secret\\injected.cs:line 42"); + try { throw failure; } + catch (Exception caught) + { + string path = Assert.IsType(LocalCrashReportWriter.TryWrite(caught, _directory)); + string text = File.ReadAllText(path); + Assert.DoesNotContain("PRIVATE_", text); + Assert.DoesNotContain("secret", text); + Assert.DoesNotContain(".cs", text); + using JsonDocument document = JsonDocument.Parse(text); + Assert.Contains(nameof(MessagesDataSourcePathsAndInjectedRemoteStack_AreNeverSerialized), + document.RootElement.GetProperty("exceptions")[0].GetProperty("stack").GetString()); + } + } + + [Fact] + public void VirtualExceptionTextAccessors_AreNotRead() + { + using JsonDocument document = Write(new PoisonTextException()); + Assert.Equal(typeof(PoisonTextException).FullName, + document.RootElement.GetProperty("exceptions")[0].GetProperty("type").GetString()); + } + + [Fact] + public void RepeatedWrites_AreUniqueNonOverwritingAndLeaveOnlyCompleteJson() + { + string sentinel = Path.Combine(_directory, "existing.json"); + File.WriteAllText(sentinel, "existing report must survive"); + string first = Assert.IsType(LocalCrashReportWriter.TryWrite(new Exception(), _directory)); + byte[] firstBytes = File.ReadAllBytes(first); + string second = Assert.IsType(LocalCrashReportWriter.TryWrite(new Exception(), _directory)); + Assert.NotEqual(first, second); + Assert.Equal(firstBytes, File.ReadAllBytes(first)); + Assert.Equal("existing report must survive", File.ReadAllText(sentinel)); + Assert.Equal(3, Directory.GetFiles(_directory).Length); + Assert.Empty(Directory.GetFiles(_directory, "*.tmp")); + foreach (string path in new[] { first, second }) + { + Assert.StartsWith("crash-", Path.GetFileName(path)); + Assert.Contains($"-{Environment.ProcessId}-", Path.GetFileName(path)); + using JsonDocument document = JsonDocument.Parse(File.ReadAllBytes(path)); + Assert.Equal(1, document.RootElement.GetProperty("schemaVersion").GetInt32()); + } + } + + [Fact] + public void FailedContextCapture_StillWritesMinimalReportWithoutSecondaryText() + { + using JsonDocument document = Write(new Exception(), () => throw new Exception("PRIVATE_CAPTURE")); + Assert.True(document.RootElement.GetProperty("contextCaptureFailed").GetBoolean()); + Assert.Equal(JsonValueKind.Null, document.RootElement.GetProperty("gpu").ValueKind); + Assert.Equal(JsonValueKind.Null, document.RootElement.GetProperty("world").ValueKind); + Assert.DoesNotContain("PRIVATE_CAPTURE", document.RootElement.ToString()); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void InvalidOrBlockedDestination_DoesNotThrowOrChangeOriginalFailure(bool blockedByFile) + { + string destination = "\0"; + if (blockedByFile) + { + destination = Path.Combine(_directory, "not-a-directory"); + File.WriteAllText(destination, "preserve"); + } + var notices = new List(); + var original = new InvalidOperationException("original"); + Exception? propagated = Record.Exception((Action)(() => + { + try { throw original; } + catch (Exception failure) + { + Assert.Null(LocalCrashReportWriter.TryWrite(failure, destination, notify: notices.Add)); + throw; + } + })); + Assert.Same(original, propagated); + Assert.Equal("Local crash report unavailable.", Assert.Single(notices)); + Assert.Empty(Directory.GetFiles(_directory, "*.tmp")); + Assert.Empty(Directory.GetFiles(_directory, "*.json")); + if (blockedByFile) + Assert.Equal("preserve", File.ReadAllText(destination)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void FailedNotification_DoesNotThrowOrChangeOriginalFailure(bool invalidDestination) + { + var original = new Exception("original"); + string? path = null; + int notices = 0; + Exception? propagated = Record.Exception((Action)(() => + { + try { throw original; } + catch (Exception failure) + { + path = LocalCrashReportWriter.TryWrite(failure, invalidDestination ? "\0" : _directory, + notify: _ => { notices++; throw new Exception("PRIVATE_NOTIFICATION"); }); + throw; + } + })); + Assert.Same(original, propagated); + Assert.Equal(1, notices); + if (invalidDestination) + Assert.Null(path); + else + { + using JsonDocument document = JsonDocument.Parse(File.ReadAllBytes(Assert.IsType(path))); + Assert.DoesNotContain("PRIVATE_NOTIFICATION", document.RootElement.ToString()); + } + } + + private JsonDocument Write(Exception failure, Func? capture = null) => + JsonDocument.Parse(File.ReadAllBytes(Assert.IsType(LocalCrashReportWriter.TryWrite(failure, _directory, capture)))); + + private static int? NullableInt(JsonElement value) => value.ValueKind == JsonValueKind.Null ? null : value.GetInt32(); + + private static VulkanCallException ThrownVulkan() + { + try { throw new VulkanCallException("vkQueueSubmit2 (abandoned frame timeline signal)", Result.ErrorDeviceLost); } + catch (VulkanCallException failure) { return failure; } + } + + private sealed class PoisonTextException : Exception + { + public override string Message => throw new InvalidOperationException("PRIVATE_MESSAGE"); + public override string StackTrace => throw new InvalidOperationException("PRIVATE_STACK"); + public override IDictionary Data => throw new InvalidOperationException("PRIVATE_DATA"); + public override string ToString() => throw new InvalidOperationException("PRIVATE_TOSTRING"); + } + + public void Dispose() => Directory.Delete(_directory, recursive: true); +} diff --git a/tests/AcDream.App.Tests/Rendering/GameWindowCrashStatusTests.cs b/tests/AcDream.App.Tests/Rendering/GameWindowCrashStatusTests.cs index 12b70ca3..c1d5de90 100644 --- a/tests/AcDream.App.Tests/Rendering/GameWindowCrashStatusTests.cs +++ b/tests/AcDream.App.Tests/Rendering/GameWindowCrashStatusTests.cs @@ -1,5 +1,6 @@ using System.Reflection; using System.Reflection.Emit; +using AcDream.App.Diagnostics; using AcDream.App.Rendering; using AcDream.App.Tests.Architecture; using AcDream.Runtime.Session; @@ -16,7 +17,7 @@ namespace AcDream.App.Tests.Rendering; public sealed class GameWindowCrashStatusTests { [Fact] - public void Run_LatchesRunFailureBeforeRethrowingFromTheFrameLoopCatch() + public void Run_LatchesThenReportsBeforeRethrowingFromTheFrameLoopCatch() { MethodInfo run = RequiredMethod(nameof(GameWindow.Run)); IReadOnlyList calls = CompiledCallGraph.Read(run); @@ -37,10 +38,41 @@ public sealed class GameWindowCrashStatusTests CompiledInstruction rethrow = Assert.Single( CompiledCallGraph.ReadInstructions(run), instruction => instruction.OpCode == OpCodes.Rethrow); + CompiledCall report = Assert.Single(calls, call => + call.Target.DeclaringType == typeof(LocalCrashReportWriter) + && call.Target.Name == nameof(LocalCrashReportWriter.TryWrite)); Assert.True(frameLoop.Offset < retain.Offset); Assert.True(retain.Offset < latch.Offset); - Assert.True(latch.Offset < rethrow.Offset); + Assert.True(latch.Offset < report.Offset); + Assert.True(report.Offset < rethrow.Offset); + + ExceptionHandlingClause outerCatch = Assert.Single(run.GetMethodBody()!.ExceptionHandlingClauses, + clause => clause.Flags == ExceptionHandlingClauseOptions.Clause + && clause.CatchType == typeof(Exception) + && clause.HandlerOffset <= retain.Offset + && retain.Offset < clause.HandlerOffset + clause.HandlerLength); + Assert.InRange(rethrow.Offset, outerCatch.HandlerOffset, outerCatch.HandlerOffset + outerCatch.HandlerLength - 1); + ExceptionHandlingClause reportGuard = Assert.Single(run.GetMethodBody()!.ExceptionHandlingClauses, + clause => clause.Flags == ExceptionHandlingClauseOptions.Clause + && clause.TryOffset <= report.Offset + && report.Offset < clause.TryOffset + clause.TryLength); + Assert.True(latch.Offset < reportGuard.TryOffset); + foreach (CompiledCall call in calls.Where(call => call.Offset > latch.Offset && call.Offset <= report.Offset)) + Assert.InRange(call.Offset, reportGuard.TryOffset, reportGuard.TryOffset + reportGuard.TryLength - 1); + Assert.Single(CompiledCallGraph.ReadDeclared(typeof(GameWindow)), call => + call.Target.DeclaringType == typeof(LocalCrashReportWriter)); + } + + [Fact] + public void CrashContext_ReadsControllerOnceAndUsesExistingCellPositionAndCachedGpuMetadata() + { + IReadOnlyList calls = CompiledCallGraph.Read(RequiredMethod("CaptureLocalCrashReportContext")); + foreach (string getter in new[] { "get__playerController", "get_CellPosition", "get_State", "get_Capabilities", "get_Width", "get_Height", "get_SampleCount" }) + Assert.Single(calls, call => call.Target.Name == getter); + Assert.DoesNotContain(calls, call => call.Target.Name == "get_Position" || call.Target.Name == "get_Device"); + Assert.DoesNotContain(CompiledCallGraph.Read(RequiredMethod("ReportExited")), call => + call.Target.DeclaringType == typeof(LocalCrashReportWriter)); } [Fact]