make settings persistence safe for nested data and crashes (T-376)

Three failure modes in the YAML store: maps nested inside lists (the
documented keymap-overlay shape) fell through _emitScalar to
toString() and corrupted on the next read; writes went straight to
the live file, so a crash mid-write truncated every setting; and a
parse failure silently returned an empty map that the next set()
wrote over the user's file. Maps in lists now emit as YAML flow
mappings, writes are temp-file + rename, and an unparseable file is
preserved as .broken with a warning through the kernel Logger (new
onError hook, wired in the facade).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 01:16:48 +02:00
co-authored by Claude Fable 5
parent 5d52694889
commit e413380ea9
6 changed files with 98 additions and 8 deletions
@@ -4005,3 +4005,5 @@ INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, chang
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHCAFKK334YNJXZJQG4J6AW', 'status', 'in_progress', 'done', NULL, '2026-06-11 23:10:56', '2026-06-11 23:10:56', '2026-06-11 23:10:56', NULL, '4b505b885dc39785651e259370fbc97b', 2) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHCAFKK334YNJXZJQG4J6AW', 'status', 'in_progress', 'done', NULL, '2026-06-11 23:10:56', '2026-06-11 23:10:56', '2026-06-11 23:10:56', NULL, '4b505b885dc39785651e259370fbc97b', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHC5ZE4EZEGXK8YY8J86CM0', 'status', 'backlog', 'in_progress', NULL, '2026-06-11 23:11:24', '2026-06-11 23:11:24', '2026-06-11 23:11:24', NULL, '4763adf4924a8f8452c393db9ad03868', 2) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHC5ZE4EZEGXK8YY8J86CM0', 'status', 'backlog', 'in_progress', NULL, '2026-06-11 23:11:24', '2026-06-11 23:11:24', '2026-06-11 23:11:24', NULL, '4763adf4924a8f8452c393db9ad03868', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHC5ZE4EZEGXK8YY8J86CM0', 'status', 'in_progress', 'done', NULL, '2026-06-11 23:13:18', '2026-06-11 23:13:18', '2026-06-11 23:13:18', NULL, '5382c848b654d1a93daed15a28d652cf', 2) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHC5ZE4EZEGXK8YY8J86CM0', 'status', 'in_progress', 'done', NULL, '2026-06-11 23:13:18', '2026-06-11 23:13:18', '2026-06-11 23:13:18', NULL, '5382c848b654d1a93daed15a28d652cf', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHCC6AR37VTF4SY8DR99JHC', 'status', 'backlog', 'in_progress', NULL, '2026-06-11 23:13:49', '2026-06-11 23:13:49', '2026-06-11 23:13:49', NULL, '0edb800f29a8851306f7d21fb546d342', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHCC6AR37VTF4SY8DR99JHC', 'status', 'in_progress', 'done', NULL, '2026-06-11 23:16:28', '2026-06-11 23:16:28', '2026-06-11 23:16:28', NULL, '2fe291251d1a4789c8d2b75a2c32396f', 2) ON CONFLICT(hash) DO NOTHING;
+10
View File
@@ -4129,3 +4129,13 @@ INSERT INTO tickets (record_id, type, parent_record_id, title, description, stat
Fix in one move: client.cast<List<int>>().transform(utf8.decoder).transform(const LineSplitter()) consumed with await for — gives correct framing, persistent UTF-8 decoding, and true serialization at once. Fix in one move: client.cast<List<int>>().transform(utf8.decoder).transform(const LineSplitter()) consumed with await for — gives correct framing, persistent UTF-8 decoding, and true serialization at once.
Acceptance: test sending two pipelined requests in a single write (responses arrive in order, both handled); test a request split mid-UTF-8-rune across two socket writes; existing IPC tests stay green. Runs under dart test — keep imports Flutter-free.', 'done', 'medium', NULL, NULL, NULL, '2026-06-11 21:57:31', '2026-06-11 23:13:18', NULL, 'bbc0aa50303eac50666cbcfa140065a1', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); Acceptance: test sending two pipelined requests in a single write (responses arrive in order, both handled); test a request split mid-UTF-8-rune across two socket writes; existing IPC tests stay green. Runs under dart test — keep imports Flutter-free.', 'done', 'medium', NULL, NULL, NULL, '2026-06-11 21:57:31', '2026-06-11 23:13:18', NULL, 'bbc0aa50303eac50666cbcfa140065a1', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHCC6AR37VTF4SY8DR99JHC', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Settings persistence corrupts maps-inside-lists on write; non-atomic; parse failure silently resets', 'lib/kernel/src/settings.dart:199-219 — the writer emits toString() for maps nested inside lists, corrupting them on the next read; this breaks the documented keymap overlay across restarts. Writes are also non-atomic (a crash mid-write truncates the file), and a parse failure on load silently resets ALL settings instead of preserving the file and surfacing the error.
Fix: serialize with a real encoder (JSON/YAML emitter, whatever the file format is) covering nested structures; write to a temp file + rename for atomicity; on parse failure keep the original file (e.g. move aside as .broken) and log via the kernel Logger instead of resetting.
Acceptance: round-trip test for a keymap overlay (list of maps) across save/load; simulated partial write leaves previous settings intact; corrupt file does not silently reset and produces a logged diagnostic.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-11 21:58:22', '2026-06-11 23:13:49', NULL, '71635348a33e10c188ea10581c39ca57', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHCC6AR37VTF4SY8DR99JHC', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Settings persistence corrupts maps-inside-lists on write; non-atomic; parse failure silently resets', 'lib/kernel/src/settings.dart:199-219 — the writer emits toString() for maps nested inside lists, corrupting them on the next read; this breaks the documented keymap overlay across restarts. Writes are also non-atomic (a crash mid-write truncates the file), and a parse failure on load silently resets ALL settings instead of preserving the file and surfacing the error.
Fix: serialize with a real encoder (JSON/YAML emitter, whatever the file format is) covering nested structures; write to a temp file + rename for atomicity; on parse failure keep the original file (e.g. move aside as .broken) and log via the kernel Logger instead of resetting.
Acceptance: round-trip test for a keymap overlay (list of maps) across save/load; simulated partial write leaves previous settings intact; corrupt file does not silently reset and produces a logged diagnostic.', 'done', 'medium', NULL, NULL, NULL, '2026-06-11 21:58:22', '2026-06-11 23:16:28', NULL, 'e23aad60b9f75e50cb305e727254e094', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
+6
View File
@@ -109,6 +109,12 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
D-72's contract), drop or double frames split across reads, and corrupt D-72's contract), drop or double frames split across reads, and corrupt
multi-byte characters split across chunks. (T-372) multi-byte characters split across chunks. (T-372)
- **Settings survive nested structures, crashes, and corruption.** Maps
inside lists (the keymap overlay shape) were corrupted on save; writes
are now atomic (temp file + rename), and a file that fails to parse is
preserved as `.broken` with a logged warning instead of being silently
reset. (T-376)
- **Accepting ExitPlanMode now leaves plan mode in the conversation panel.** - **Accepting ExitPlanMode now leaves plan mode in the conversation panel.**
Approving Claude's plan (the ExitPlanMode tool) transitioned the underlying Approving Claude's plan (the ExitPlanMode tool) transitioned the underlying
session out of plan mode, but clide's tracked permission mode didn't follow, session out of plan mode, but clide's tracked permission mode didn't follow,
+1 -1
View File
@@ -144,7 +144,7 @@ class KernelServices {
final messages = MessageBus(); final messages = MessageBus();
final filterStates = FilterStateCache(messages: messages); final filterStates = FilterStateCache(messages: messages);
final settings = SettingsStore(appDir: appDir); final settings = SettingsStore(appDir: appDir, onError: (m) => log.warn('settings', m));
await settings.load(); await settings.load();
final i18n = I18n(loader: i18nLoader, log: log, defaultLocale: defaultLocale, initialLocale: initialLocale, availableLocales: availableLocales); final i18n = I18n(loader: i18nLoader, log: log, defaultLocale: defaultLocale, initialLocale: initialLocale, availableLocales: availableLocales);
+42 -7
View File
@@ -6,11 +6,16 @@ import 'package:yaml/yaml.dart';
enum SettingsScope { app, project, ext } enum SettingsScope { app, project, ext }
class SettingsStore extends ChangeNotifier { class SettingsStore extends ChangeNotifier {
SettingsStore({required this.appDir, this.projectDir}); SettingsStore({required this.appDir, this.projectDir, this.onError});
final Directory appDir; final Directory appDir;
Directory? projectDir; Directory? projectDir;
/// Surfaces load/parse problems (wired to the kernel Logger by the
/// facade). A parse failure must not pass silently — it used to reset
/// every setting on the next write (T-376).
final void Function(String message)? onError;
final Map<String, Object?> _appValues = <String, Object?>{}; final Map<String, Object?> _appValues = <String, Object?>{};
final Map<String, Object?> _projectValues = <String, Object?>{}; final Map<String, Object?> _projectValues = <String, Object?>{};
@@ -93,17 +98,29 @@ class SettingsStore extends ChangeNotifier {
} }
Future<Map<String, Object?>> _readFile(File f) async { Future<Map<String, Object?>> _readFile(File f) async {
String txt;
try { try {
if (!await f.exists()) return <String, Object?>{}; if (!await f.exists()) return <String, Object?>{};
final txt = await f.readAsString(); txt = await f.readAsString();
if (txt.trim().isEmpty) return <String, Object?>{}; } catch (_) {
// On web (or in sandboxes where the path isn't readable) silently
// degrade to an empty in-memory catalog. `set` will no-op too.
return <String, Object?>{};
}
if (txt.trim().isEmpty) return <String, Object?>{};
try {
final yaml = loadYaml(txt); final yaml = loadYaml(txt);
final out = <String, Object?>{}; final out = <String, Object?>{};
if (yaml is Map) _flatten(yaml, '', out); if (yaml is Map) _flatten(yaml, '', out);
return out; return out;
} catch (_) { } catch (e) {
// On web (or in sandboxes where the path isn't writable) silently // A parse failure must not silently reset the user's settings — the
// degrade to an empty in-memory catalog. `set` will no-op too. // next `set` overwrites the file with the (now empty) in-memory map.
// Preserve the original for recovery and say so (T-376).
try {
await File('${f.path}.broken').writeAsString(txt);
} catch (_) {}
onError?.call('failed to parse ${f.path}: $e — original preserved at ${f.path}.broken');
return <String, Object?>{}; return <String, Object?>{};
} }
} }
@@ -111,7 +128,11 @@ class SettingsStore extends ChangeNotifier {
Future<void> _writeFile(File f, Map<String, Object?> flat) async { Future<void> _writeFile(File f, Map<String, Object?> flat) async {
try { try {
await f.parent.create(recursive: true); await f.parent.create(recursive: true);
await f.writeAsString(_emitYaml(_unflatten(flat))); // Temp-file + rename: a crash mid-write must not truncate the live
// settings file (T-376).
final tmp = File('${f.path}.tmp');
await tmp.writeAsString(_emitYaml(_unflatten(flat)));
await tmp.rename(f.path);
} catch (_) { } catch (_) {
// Web / read-only sandbox: in-memory update remains valid, we // Web / read-only sandbox: in-memory update remains valid, we
// just can't persist. Callers already called notifyListeners. // just can't persist. Callers already called notifyListeners.
@@ -214,6 +235,20 @@ void _emitScalar(StringBuffer buf, Object? v) {
_emitScalar(buf, v[i]); _emitScalar(buf, v[i]);
} }
buf.write(']'); buf.write(']');
} else if (v is Map) {
// YAML flow mapping — maps nested inside lists (e.g. keymap overlay
// entries) used to fall through to toString() and corrupt on the
// next read (T-376).
buf.write('{');
var first = true;
v.forEach((k, vv) {
if (!first) buf.write(', ');
first = false;
_emitScalar(buf, '$k');
buf.write(': ');
_emitScalar(buf, vv);
});
buf.write('}');
} else { } else {
buf.write('"${v.toString()}"'); buf.write('"${v.toString()}"');
} }
+37
View File
@@ -170,6 +170,43 @@ void main() {
expect(store.get<int>('app.anything'), isNull); expect(store.get<int>('app.anything'), isNull);
}); });
// T-376: maps nested inside lists were emitted via toString() and
// corrupted on the next read — breaking the documented keymap overlay.
test('maps inside lists round-trip across save/load (keymap overlay shape)', () async {
final overlay = [
{'keys': 'ctrl+k ctrl+s', 'command': 'keybindings.open'},
{'keys': 'shift shift', 'command': 'finder.open', 'when': 'editorFocus'},
];
await store.set<Object>('app.keymap.overlay', overlay);
final loaded = SettingsStore(appDir: tmp);
addTearDown(loaded.dispose);
await loaded.load();
final got = loaded.get<List>('app.keymap.overlay');
expect(got, hasLength(2));
expect((got![0] as Map)['keys'], 'ctrl+k ctrl+s');
expect((got[0] as Map)['command'], 'keybindings.open');
expect((got[1] as Map)['when'], 'editorFocus');
});
test('a parse failure preserves the original file and reports it (T-376)', () async {
final errors = <String>[];
final f = File('${tmp.path}/settings.yaml');
const garbage = 'app:\n broken: [unclosed\n'; // genuinely invalid YAML
await f.writeAsString(garbage);
final reporting = SettingsStore(appDir: tmp, onError: errors.add);
addTearDown(reporting.dispose);
await reporting.load();
expect(errors, hasLength(1));
expect(errors.single, contains('.broken'));
expect(File('${f.path}.broken').readAsStringSync(), garbage, reason: 'the broken original is preserved for recovery');
});
test('writes are atomic — no .tmp residue, content lands whole', () async {
await store.set<String>('app.k', 'v');
expect(File('${tmp.path}/settings.yaml.tmp').existsSync(), isFalse);
expect(File('${tmp.path}/settings.yaml').readAsStringSync(), contains('k: v'));
});
test('load returns empty when the settings file is blank or missing', () async { test('load returns empty when the settings file is blank or missing', () async {
// File missing → empty. // File missing → empty.
final f = File('${tmp.path}/settings.yaml'); final f = File('${tmp.path}/settings.yaml');