Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ Notable changes to Wisp are recorded here.
- End the process at once when a shutdown wait expires instead of finishing the shutdown under a worker that is still inside a driver call; the next run reports an incomplete shutdown rather than a crash.
- Show the update repair instructions once and stop automatic update attempts for the session when the update helper is missing. Install update remains available from the dashboard.
- Report the drift gauge setting in environment snapshots.
- Crash reports name the faulting module, its version and offset, and include the call stack Wisp captured at the fault.

## 2.6.12 - 2026-10-16

Expand Down
1 change: 1 addition & 0 deletions docs/releases/Wisp-2.6.13-release-notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@
## Diagnostics

- Wisp reports whether the drift gauge is switched on, so Wisp Tools shows feature use accurately.
- Crash reports name the faulting module, its version and offset, and include the call stack Wisp captured at the fault.
3 changes: 3 additions & 0 deletions src/Wisp.App/App.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,9 @@ protected override async void OnStartup(StartupEventArgs e)
}

_runExitMarker = RunExitMarkerStore.BeginCurrent(_runId);
// The native handler records the faulting address and call stack of a hardware fault
// for this run; the next run reports it with the crash.
NativeCrashCapture.TryInstall(_crashReports.StoreDirectory, _runId);
_activationEvent = new EventWaitHandle(
initialState: false,
EventResetMode.AutoReset,
Expand Down
5 changes: 5 additions & 0 deletions src/Wisp.App/CrashDiagnostics/CrashReport.cs
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,11 @@ internal string Format()
report.AppendLine("The faulting module alone does not establish the root cause.");
}
else report.AppendLine("The previous run did not finish normal shutdown. The cause is not established; termination or power loss can also leave this marker.");
if (exit.Native is { } native)
{
report.AppendLine($"Faulting address: {native.Module}+0x{native.Offset.ToString("X", CultureInfo.InvariantCulture)} (code 0x{native.ExceptionCode.ToString("X8", CultureInfo.InvariantCulture)}, thread {native.ThreadId})");
foreach (var frame in native.Frames) report.AppendLine($"Frame: {frame}");
}
}
for (var i = 0; i < safe.Exceptions.Length; i++)
{
Expand Down
1 change: 1 addition & 0 deletions src/Wisp.App/CrashDiagnostics/CrashReportStore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ internal sealed class CrashReportStore(string directory, Action<string, byte[]>?
private long _recoveryGeneration;
internal long RecoveryGeneration { get { lock (_gate) return _recoveryGeneration; } }
private readonly string _path = Path.Combine(directory, "reports.json");
internal string StoreDirectory => directory;
private sealed record Entry(CrashReport Report, bool Acknowledged);
private sealed record Document(int Version, Entry[] Reports);

Expand Down
145 changes: 145 additions & 0 deletions src/Wisp.App/CrashDiagnostics/NativeCrashCapture.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
using System.Globalization;
using System.IO;
using System.Runtime.InteropServices;

namespace Wisp.App.CrashDiagnostics;

// What the native renderer's vectored exception handler wrote when a hardware fault hit the
// process: the faulting address and a call stack, each as module+offset. Windows' own event
// only keeps the faulting module and offset; this is what locates the fault.
internal sealed record NativeFaultRecord(uint ExceptionCode, string Module, ulong Offset, string? Access,
ulong? AccessAddress, uint ThreadId, string[] Frames)
{
internal const int MaximumFrames = 48;
internal const int MaximumRecordBytes = 64 * 1024;

internal NativeFaultRecord? Sanitize()
{
var module = WindowsFaultEventReader.SafeModule(Module);
if (module is null || Frames is null) return null;
if (Access is not (null or "read" or "write" or "execute")) return null;
var frames = Frames.Where(ValidFrame).Take(MaximumFrames).ToArray();
return this with { Module = module, Frames = frames };
}

internal static bool ValidFrame(string? frame)
{
if (frame is not { Length: <= 96 }) return false;
var plus = frame.IndexOf("+0x", StringComparison.Ordinal);
if (plus <= 0) return false;
var module = frame[..plus];
var offset = frame[(plus + 3)..];
return WindowsFaultEventReader.SafeModule(module) == module && offset.Length is >= 1 and <= 16 &&
offset.All(character => character is >= '0' and <= '9' or >= 'a' and <= 'f');
}

// Parses the last complete record in the file. A record is a block of key=value lines
// between "wisp-native-fault 1" and "end".
internal static NativeFaultRecord? Parse(string text)
{
if (string.IsNullOrEmpty(text)) return null;
var blocks = text.Split("wisp-native-fault 1\n", StringSplitOptions.RemoveEmptyEntries);
for (var index = blocks.Length - 1; index >= 0; index--)
{
var block = blocks[index];
var end = block.IndexOf("\nend\n", StringComparison.Ordinal);
if (end < 0) continue;
uint code = 0, thread = 0;
string? module = null, access = null;
ulong offset = 0, accessAddress = 0;
var hasAccessAddress = false;
var frames = new List<string>();
foreach (var line in block[..end].Split('\n', StringSplitOptions.RemoveEmptyEntries))
{
var separator = line.IndexOf('=');
if (separator <= 0) continue;
var key = line[..separator];
var value = line[(separator + 1)..].Trim();
switch (key)
{
case "code": if (!Hex(value, out var parsedCode) || parsedCode > uint.MaxValue) return null; code = (uint)parsedCode; break;
case "thread": if (!uint.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out thread)) return null; break;
case "address":
if (!SplitAddress(value, out module, out offset)) return null;
break;
case "access":
var parts = value.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries);
if (parts.Length != 2 || !Hex(parts[1], out accessAddress)) return null;
access = parts[0];
hasAccessAddress = true;
break;
case "frame":
if (frames.Count < MaximumFrames && ValidFrame(value)) frames.Add(value);
break;
}
}
if (module is null) return null;
return new NativeFaultRecord(code, module, offset, access, hasAccessAddress ? accessAddress : null, thread, frames.ToArray()).Sanitize();
}
return null;
}

private static bool SplitAddress(string value, out string? module, out ulong offset)
{
module = null;
offset = 0;
var plus = value.IndexOf("+0x", StringComparison.Ordinal);
if (plus <= 0 || !Hex(value[(plus + 3)..], out offset)) return false;
module = WindowsFaultEventReader.SafeModule(value[..plus]);
return module is not null;
}

private static bool Hex(string value, out ulong number)
{
number = 0;
if (value.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) value = value[2..];
return value is { Length: > 0 and <= 16 } && value.All(char.IsAsciiHexDigit) &&
ulong.TryParse(value, NumberStyles.AllowHexSpecifier, CultureInfo.InvariantCulture, out number);
}
}

internal static class NativeCrashCapture
{
private static class Native
{
[DllImport("Wisp.NativeRenderer.dll", EntryPoint = "WispCrashCaptureInstall", CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Unicode)]
internal static extern int Install(string path);
}

internal static string PathFor(string directory, Guid runId) =>
Path.Combine(directory, "native-" + runId.ToString("N", CultureInfo.InvariantCulture) + ".txt");

// Installs the handler for this run. Failure only means a future fault stays unlocated.
internal static bool TryInstall(string directory, Guid runId)
{
try
{
Directory.CreateDirectory(directory);
return Native.Install(PathFor(directory, runId)) >= 0;
}
catch (Exception error) when (error is DllNotFoundException or EntryPointNotFoundException or BadImageFormatException
or IOException or UnauthorizedAccessException or ArgumentException)
{
return false;
}
}

// Reads and removes the previous run's record. Absent or unreadable records are null.
internal static NativeFaultRecord? TryRead(string directory, Guid runId)
{
var path = PathFor(directory, runId);
try
{
if (!File.Exists(path)) return null;
NativeFaultRecord? record = null;
var info = new FileInfo(path);
if (info.Length is > 0 and <= NativeFaultRecord.MaximumRecordBytes) record = NativeFaultRecord.Parse(File.ReadAllText(path));
try { File.Delete(path); } catch (Exception) { }
return record;
}
catch (Exception error) when (error is IOException or UnauthorizedAccessException)
{
return null;
}
}
}
1 change: 1 addition & 0 deletions src/Wisp.App/CrashDiagnostics/PreviousRunRecovery.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ internal sealed class PreviousRunRecovery(CrashReportStore reports, IWindowsFaul
cancellationToken.ThrowIfCancellationRequested();
var exit = new RecoveredExitInfo(safe.StartedAtUtc, nextRunStartedAtUtc, lookup.Status, lookup.Fault).Sanitize();
if (exit is null) exit = new(safe.StartedAtUtc, nextRunStartedAtUtc, WindowsFaultLookupStatus.ReadFailed, null);
exit = exit with { Native = NativeCrashCapture.TryRead(reports.StoreDirectory, safe.RunId) };
// A recorded close request is not a crash. A correlated Windows fault still wins.
if (safe.ShutdownIntent != ShutdownIntent.None && exit.Fault is null) return null;
var report = safe.ToReport(exit.Fault?.TimeUtc ?? nextRunStartedAtUtc) with
Expand Down
43 changes: 32 additions & 11 deletions src/Wisp.App/CrashDiagnostics/WindowsFaultEventReader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,18 @@ namespace Wisp.App.CrashDiagnostics;

internal enum WindowsFaultLookupStatus { NoMatchingEvent, Matched, AccessDenied, TimedOut, ReadFailed, ScanLimitReached }
internal sealed record WindowsFaultEvidence(DateTimeOffset TimeUtc, uint ExceptionCode, ulong? FaultOffset,
string? Module, bool CorrelatedWerReport);
string? Module, bool CorrelatedWerReport)
{
// The faulting module's file version, as Windows recorded it.
public string? ModuleVersion { get; init; }
}
internal sealed record WindowsFaultLookup(WindowsFaultLookupStatus Status, WindowsFaultEvidence? Fault = null);
internal sealed record RecoveredExitInfo(DateTimeOffset RunStartedAtUtc, DateTimeOffset DetectedAtUtc,
WindowsFaultLookupStatus LookupStatus, WindowsFaultEvidence? Fault)
{
// The in-process capture of the fault, when the native handler wrote one for that run.
public NativeFaultRecord? Native { get; init; }

internal RecoveredExitInfo? Sanitize()
{
if (RunStartedAtUtc.Offset != TimeSpan.Zero || RunStartedAtUtc.Ticks <= 0 ||
Expand All @@ -22,9 +29,13 @@ internal sealed record RecoveredExitInfo(DateTimeOffset RunStartedAtUtc, DateTim
{
if (Fault is not { } fault || fault.TimeUtc.Offset != TimeSpan.Zero ||
fault.TimeUtc < RunStartedAtUtc || fault.TimeUtc > DetectedAtUtc) return null;
return this with { Fault = fault with { Module = WindowsFaultEventReader.SafeModule(fault.Module) } };
return this with
{
Fault = fault with { Module = WindowsFaultEventReader.SafeModule(fault.Module), ModuleVersion = WindowsFaultEventReader.SafeVersion(fault.ModuleVersion) },
Native = Native?.Sanitize()
};
}
return this with { Fault = null };
return this with { Fault = null, Native = Native?.Sanitize() };
}
}
internal interface IWindowsFaultEventSource
Expand All @@ -36,12 +47,6 @@ internal sealed class WindowsFaultEventReader : IWindowsFaultEventSource
{
internal const int MaximumEvents = 64, MaximumXmlCharacters = 65536;
private static readonly XNamespace Namespace = "http://schemas.microsoft.com/win/2004/08/events/event";
private static readonly HashSet<string> Modules = new(StringComparer.OrdinalIgnoreCase)
{
"wisp.exe", "wisp.dll", "wisp.nativerenderer.dll", "libmpv-2.dll", "libvlc.dll", "libvlccore.dll",
"ntdll.dll", "kernel32.dll", "kernelbase.dll", "ucrtbase.dll", "vcruntime140.dll", "vcruntime140_1.dll",
"msvcp140.dll", "coreclr.dll", "clrjit.dll", "d3d11.dll", "dxgi.dll", "dcomp.dll"
};

public WindowsFaultLookup Read(RunExitMarker previous, DateTimeOffset nextRunStartedAtUtc, CancellationToken cancellationToken)
{
Expand Down Expand Up @@ -125,7 +130,8 @@ internal static WindowsFaultLookup Correlate(IEnumerable<string> events, RunExit
!Hex(Value("ExceptionCode"), out var code) || code > uint.MaxValue) continue;
if (fault is not null && fault.TimeUtc >= time) continue;
fault = new(time, (uint)code, Hex(Value("FaultingOffset"), out var offset) ? offset : null,
SafeModule(Value("ModuleName")), false);
SafeModule(Value("ModuleName")), false)
{ ModuleVersion = SafeVersion(Value("ModuleVersion")) };
faultReport = Guid.TryParse(Value("IntegratorReportId"), out var reportId) && reportId != Guid.Empty ? reportId : null;
}
catch (Exception error) when (error is XmlException or ArgumentException or InvalidOperationException) { }
Expand All @@ -134,7 +140,22 @@ internal static WindowsFaultLookup Correlate(IEnumerable<string> events, RunExit
fault with { CorrelatedWerReport = faultReport is { } report && werReports.Contains(report) });
}

internal static string? SafeModule(string? module) => module is not null && Modules.Contains(module) ? module.ToLowerInvariant() : null;
// Any module can fault, including hooks other software injects, so every bare file name is
// kept: lower case, at most 64 characters of letters, digits, '.', '_' and '-', with a
// library, executable or driver extension. Paths and anything else are dropped.
internal static string? SafeModule(string? module)
{
if (module is null) return null;
var name = module.Trim().ToLowerInvariant();
if (name == "unknown") return name;
if (name.Length is 0 or > 64 || !char.IsAsciiLetterOrDigit(name[0]) ||
!name.All(character => char.IsAsciiLetterOrDigit(character) || character is '.' or '_' or '-')) return null;
return name.EndsWith(".dll", StringComparison.Ordinal) || name.EndsWith(".exe", StringComparison.Ordinal) ||
name.EndsWith(".sys", StringComparison.Ordinal) || name.EndsWith(".drv", StringComparison.Ordinal) ? name : null;
}
internal static string? SafeVersion(string? version) => version is { Length: > 0 and <= 23 } && VersionPattern.IsMatch(version) ? version : null;
private static readonly System.Text.RegularExpressions.Regex VersionPattern =
new(@"^[0-9]{1,5}(\.[0-9]{1,5}){3}$", System.Text.RegularExpressions.RegexOptions.CultureInvariant);
private static bool Unsigned(string? value, out ulong number) => value?.StartsWith("0x", StringComparison.OrdinalIgnoreCase) == true
? Hex(value, out number) : ulong.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out number);
private static bool Hex(string? value, out ulong number)
Expand Down
5 changes: 4 additions & 1 deletion src/Wisp.App/DebugLogging/SupplementaryIncidentCapture.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,10 @@ internal static SupplementaryIncident Crash(CrashReport report)
failureStage: "crash-" + origin,
code: safe?.RecoveredExit?.Fault?.ExceptionCode ?? (safe?.Exceptions.FirstOrDefault() is { } first ? unchecked((uint)first.HResult) : null),
exceptions: Exceptions(safe?.Exceptions), context: Context(safe?.Context, report.TimeUtc,
recovered: safe?.RecoveredExit is not null), crash: new(origin, lookup, safe?.RecoveredExit?.Fault?.Module));
recovered: safe?.RecoveredExit is not null), crash: new(origin, lookup, safe?.RecoveredExit?.Fault?.Module,
safe?.RecoveredExit?.Fault?.FaultOffset, safe?.RecoveredExit?.Fault?.ModuleVersion,
safe?.RecoveredExit?.Native?.Module, safe?.RecoveredExit?.Native?.Offset,
safe?.RecoveredExit?.Native is { } native ? native.Frames.ToImmutableArray() : null));
}

internal static SupplementaryIncident? Export(Exception error, DateTimeOffset now)
Expand Down
3 changes: 2 additions & 1 deletion src/Wisp.App/ReleaseNotesCatalog.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ public static class ReleaseNotesCatalog
"Closing Wisp while a renderer thread is stuck in a driver call ends the app cleanly instead of crashing.",
"When the update helper is missing, Wisp shows the repair instructions once and stops automatic update attempts for the session; Install update stays available on the dashboard."),
Group("Diagnostics",
"Wisp reports whether the drift gauge is switched on, so Wisp Tools shows feature use accurately.")
"Wisp reports whether the drift gauge is switched on, so Wisp Tools shows feature use accurately.",
"Crash reports name the faulting module, its version and offset, and include the call stack Wisp captured at the fault.")
]),
new(
"2.6.12",
Expand Down
Loading
Loading