Skip to content

Commit f6b138c

Browse files
committed
Add self-healing settings file with corruption recovery
Fix startup crash when settings.json is corrupted (issue #1590). - Add SettingsJsonSanitizer utility that strips null bytes, fixes missing braces, and performs property-level JSON recovery - Wrap LoadSettings/LoadSettingsAsync deserialization in try-catch with progressive recovery: sanitize text -> property-level salvage -> fall back to defaults - Back up corrupted settings files to settings.json.bak before recovery - Make SaveSettings/SaveSettingsAsync atomic using temp-file-rename pattern to prevent partial writes that cause corruption - Add comprehensive tests for all recovery scenarios https://claude.ai/code/session_01Gej4ey7eUsbPRDqFWwxKyZ
1 parent 5d8a68d commit f6b138c

3 files changed

Lines changed: 586 additions & 44 deletions

File tree

Lines changed: 273 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,273 @@
1+
using System.Text;
2+
using System.Text.Json;
3+
using System.Text.Json.Nodes;
4+
using Microsoft.Extensions.Logging;
5+
using StabilityMatrix.Core.Models.Settings;
6+
7+
namespace StabilityMatrix.Core.Services;
8+
9+
/// <summary>
10+
/// Provides methods to sanitize and recover corrupted settings JSON files.
11+
/// </summary>
12+
public static class SettingsJsonSanitizer
13+
{
14+
/// <summary>
15+
/// Strips null bytes (0x00) from raw file content.
16+
/// </summary>
17+
public static byte[] SanitizeBytes(byte[] rawBytes)
18+
{
19+
// Check if any null bytes exist first (fast path for clean files)
20+
var hasNullBytes = false;
21+
foreach (var b in rawBytes)
22+
{
23+
if (b == 0x00)
24+
{
25+
hasNullBytes = true;
26+
break;
27+
}
28+
}
29+
30+
if (!hasNullBytes)
31+
return rawBytes;
32+
33+
// Filter out null bytes
34+
var result = new byte[rawBytes.Length];
35+
var writeIndex = 0;
36+
foreach (var b in rawBytes)
37+
{
38+
if (b != 0x00)
39+
{
40+
result[writeIndex++] = b;
41+
}
42+
}
43+
44+
return result.AsSpan(0, writeIndex).ToArray();
45+
}
46+
47+
/// <summary>
48+
/// Ensures the JSON text has matching curly braces by appending missing closing braces.
49+
/// </summary>
50+
public static string TryFixBraces(string jsonText)
51+
{
52+
var openCount = 0;
53+
var inString = false;
54+
var escaped = false;
55+
56+
foreach (var c in jsonText)
57+
{
58+
if (escaped)
59+
{
60+
escaped = false;
61+
continue;
62+
}
63+
64+
if (c == '\\' && inString)
65+
{
66+
escaped = true;
67+
continue;
68+
}
69+
70+
if (c == '"')
71+
{
72+
inString = !inString;
73+
continue;
74+
}
75+
76+
if (inString)
77+
continue;
78+
79+
switch (c)
80+
{
81+
case '{':
82+
openCount++;
83+
break;
84+
case '}':
85+
openCount--;
86+
break;
87+
}
88+
}
89+
90+
if (openCount > 0)
91+
{
92+
// Trim trailing garbage after the last valid content
93+
var trimmed = jsonText.TrimEnd();
94+
95+
// If we end in the middle of a value (e.g. truncated number or string),
96+
// try to find the last complete property by removing the trailing partial content
97+
if (trimmed.Length > 0)
98+
{
99+
var lastChar = trimmed[^1];
100+
// If the last char isn't a valid JSON value terminator, trim back to the last comma or brace
101+
if (lastChar != '"' && lastChar != '}' && lastChar != ']'
102+
&& !char.IsDigit(lastChar) && lastChar != 'e' && lastChar != 'l'
103+
&& lastChar != 's' && lastChar != ',')
104+
{
105+
// Find the last comma, closing bracket, or opening brace
106+
var lastSafe = trimmed.LastIndexOfAny([',', '}', ']', '{']);
107+
if (lastSafe > 0)
108+
{
109+
trimmed = trimmed[..(lastSafe + 1)];
110+
}
111+
}
112+
113+
// Remove trailing comma before we add closing braces
114+
trimmed = trimmed.TrimEnd();
115+
if (trimmed.EndsWith(','))
116+
{
117+
trimmed = trimmed[..^1];
118+
}
119+
}
120+
121+
// Re-count braces after trimming
122+
openCount = 0;
123+
inString = false;
124+
escaped = false;
125+
foreach (var c in trimmed)
126+
{
127+
if (escaped) { escaped = false; continue; }
128+
if (c == '\\' && inString) { escaped = true; continue; }
129+
if (c == '"') { inString = !inString; continue; }
130+
if (inString) continue;
131+
switch (c)
132+
{
133+
case '{': openCount++; break;
134+
case '}': openCount--; break;
135+
}
136+
}
137+
138+
// Append missing closing braces
139+
var sb = new StringBuilder(trimmed);
140+
for (var i = 0; i < openCount; i++)
141+
{
142+
sb.Append('\n').Append('}');
143+
}
144+
145+
return sb.ToString();
146+
}
147+
148+
return jsonText;
149+
}
150+
151+
/// <summary>
152+
/// Attempts to deserialize settings JSON with progressive recovery strategies.
153+
/// Returns null if all recovery attempts fail.
154+
/// </summary>
155+
public static Settings? TryDeserializeWithRecovery(string jsonText, ILogger? logger = null)
156+
{
157+
// Step 1: Sanitize text (strip null bytes, fix braces)
158+
var sanitized = jsonText.Replace("\0", "");
159+
sanitized = TryFixBraces(sanitized);
160+
161+
// Step 2: Try direct deserialization of sanitized text
162+
try
163+
{
164+
var settings = JsonSerializer.Deserialize(sanitized, SettingsSerializerContext.Default.Settings);
165+
if (settings is not null)
166+
{
167+
logger?.LogInformation("Settings recovered after text sanitization");
168+
return settings;
169+
}
170+
}
171+
catch (JsonException ex)
172+
{
173+
logger?.LogWarning(ex, "Sanitized text still failed to deserialize, attempting property-level recovery");
174+
}
175+
176+
// Step 3: Property-level recovery using JsonNode
177+
return TryPropertyLevelRecovery(sanitized, logger);
178+
}
179+
180+
/// <summary>
181+
/// Attempts to parse JSON with JsonNode, remove corrupt properties, and re-deserialize.
182+
/// </summary>
183+
private static Settings? TryPropertyLevelRecovery(string jsonText, ILogger? logger)
184+
{
185+
JsonNode? rootNode;
186+
try
187+
{
188+
rootNode = JsonNode.Parse(
189+
jsonText,
190+
documentOptions: new JsonDocumentOptions
191+
{
192+
AllowTrailingCommas = true,
193+
CommentHandling = JsonCommentHandling.Skip
194+
}
195+
);
196+
}
197+
catch (JsonException)
198+
{
199+
// Try more aggressive cleanup: find the last valid closing brace
200+
var lastBrace = jsonText.LastIndexOf('}');
201+
if (lastBrace <= 0)
202+
{
203+
logger?.LogWarning("Could not parse JSON even with JsonNode, no recoverable content found");
204+
return null;
205+
}
206+
207+
try
208+
{
209+
rootNode = JsonNode.Parse(
210+
jsonText[..(lastBrace + 1)],
211+
documentOptions: new JsonDocumentOptions
212+
{
213+
AllowTrailingCommas = true,
214+
CommentHandling = JsonCommentHandling.Skip
215+
}
216+
);
217+
}
218+
catch (JsonException)
219+
{
220+
logger?.LogWarning("Could not parse JSON even after aggressive cleanup");
221+
return null;
222+
}
223+
}
224+
225+
if (rootNode is not JsonObject rootObject)
226+
{
227+
logger?.LogWarning("Settings JSON root is not an object");
228+
return null;
229+
}
230+
231+
// Remove properties that can't be individually serialized back
232+
var propsToRemove = new List<string>();
233+
foreach (var (key, value) in rootObject)
234+
{
235+
try
236+
{
237+
// Validate each property by serializing it to a string
238+
_ = value?.ToJsonString();
239+
}
240+
catch (Exception)
241+
{
242+
propsToRemove.Add(key);
243+
}
244+
}
245+
246+
foreach (var key in propsToRemove)
247+
{
248+
logger?.LogWarning("Removing corrupt property from settings: {Key}", key);
249+
rootObject.Remove(key);
250+
}
251+
252+
// Re-serialize the cleaned node tree and attempt typed deserialization
253+
try
254+
{
255+
var cleanedJson = rootObject.ToJsonString();
256+
var settings = JsonSerializer.Deserialize(cleanedJson, SettingsSerializerContext.Default.Settings);
257+
if (settings is not null)
258+
{
259+
logger?.LogInformation(
260+
"Settings recovered via property-level recovery ({RemovedCount} properties removed)",
261+
propsToRemove.Count
262+
);
263+
return settings;
264+
}
265+
}
266+
catch (JsonException ex)
267+
{
268+
logger?.LogWarning(ex, "Property-level recovery failed during final deserialization");
269+
}
270+
271+
return null;
272+
}
273+
}

0 commit comments

Comments
 (0)