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.
This commit is contained in:
parent
9baef7c507
commit
f9bb47ec63
10 changed files with 927 additions and 2 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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<Task> 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.
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
199
src/AcDream.App/Diagnostics/LocalCrashReportWriter.cs
Normal file
199
src/AcDream.App/Diagnostics/LocalCrashReportWriter.cs
Normal file
|
|
@ -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;
|
||||
|
||||
/// <summary>One bounded, local, best-effort report at the existing Run failure boundary.</summary>
|
||||
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<LocalCrashReportContext?>? captureContext = null,
|
||||
Action<string>? 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<ExceptionEntry>(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<AssemblyInformationalVersionAttribute>()?.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<ExceptionEntry> 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<string>? 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);
|
||||
|
|
@ -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<GameWindowGraphics, IInputContext>.PublishGraphics(
|
||||
GameWindowGraphics graphics) =>
|
||||
PublishCompositionOwner(ref _graphics, graphics, "graphics API");
|
||||
|
|
|
|||
|
|
@ -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>();
|
||||
string path = Assert.IsType<string>(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<AssemblyInformationalVersionAttribute>()!.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<string>(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<string>(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<string>(LocalCrashReportWriter.TryWrite(new Exception(), _directory));
|
||||
byte[] firstBytes = File.ReadAllBytes(first);
|
||||
string second = Assert.IsType<string>(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<string>();
|
||||
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<string>(path)));
|
||||
Assert.DoesNotContain("PRIVATE_NOTIFICATION", document.RootElement.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
private JsonDocument Write(Exception failure, Func<LocalCrashReportContext?>? capture = null) =>
|
||||
JsonDocument.Parse(File.ReadAllBytes(Assert.IsType<string>(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);
|
||||
}
|
||||
|
|
@ -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<CompiledCall> 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<CompiledCall> 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]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue