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:
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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()}"');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
Reference in New Issue
Block a user