diff --git a/src/officecli/CommandBuilder.Save.cs b/src/officecli/CommandBuilder.Save.cs index c3b68f5c9..30a23409c 100644 --- a/src/officecli/CommandBuilder.Save.cs +++ b/src/officecli/CommandBuilder.Save.cs @@ -39,6 +39,8 @@ private static Command BuildSaveCommand(Option jsonOption) // TryResident auto-start path that other verbs use. if (!ResidentClient.TryConnect(filePath, out _)) { + if (ResidentRecoveryMarker.TryConsume(filePath)) + throw ResidentRecoveryMarker.CreateLossException(filePath); // No resident session to flush. In the non-resident model the // document on disk is already current (each mutation eager-saved), // so save is a no-op SUCCESS rather than an error — keeping diff --git a/src/officecli/CommandBuilder.cs b/src/officecli/CommandBuilder.cs index 213e06703..9f89a5342 100644 --- a/src/officecli/CommandBuilder.cs +++ b/src/officecli/CommandBuilder.cs @@ -99,6 +99,8 @@ public static RootCommand BuildRootCommand() } else { + if (ResidentRecoveryMarker.TryConsume(file.FullName)) + throw ResidentRecoveryMarker.CreateLossException(file.FullName); // No resident is holding this file. In the non-resident model // every mutation already eager-saved to disk, so there is // nothing to flush or shut down — treat close as an idempotent diff --git a/src/officecli/Core/ResidentRecoveryMarker.cs b/src/officecli/Core/ResidentRecoveryMarker.cs new file mode 100644 index 000000000..355485df4 --- /dev/null +++ b/src/officecli/Core/ResidentRecoveryMarker.cs @@ -0,0 +1,111 @@ +// Copyright 2026 OfficeCLI (https://OfficeCLI.AI) +// SPDX-License-Identifier: Apache-2.0 + +using System.Security.Cryptography; +using System.Text; + +namespace OfficeCli.Core; + +/// +/// Best-effort crash marker for resident mutations that have not reached disk. +/// This does not replay edits; it prevents a later process from falsely +/// reporting that the stale on-disk document is already saved. +/// +internal static class ResidentRecoveryMarker +{ + private const string Warning = + "A previous resident ended while it may have held unflushed in-memory changes. " + + "Those changes cannot be recovered and may have been lost; the file was reopened " + + "from its last saved state."; + + internal static string WarningMessage(string filePath) + => $"WARNING: {Warning} File: {Path.GetFileName(filePath)}. " + + "For short-lived or externally managed agent processes, set " + + "OFFICECLI_RESIDENT_FLUSH=each."; + + internal static CliException CreateLossException(string filePath) + => new(WarningMessage(filePath)) + { + Code = "resident_unflushed_changes_lost", + Suggestion = "Repeat the lost edit if needed, then use OFFICECLI_RESIDENT_FLUSH=each." + }; + + internal static bool TryMark(string filePath, out string? error) + { + error = null; + string? tempPath = null; + try + { + var path = MarkerPath(filePath); + var dir = Path.GetDirectoryName(path)!; + Directory.CreateDirectory(dir); + TryRestrictDirectory(dir); + + tempPath = $"{path}.{Environment.ProcessId}.{Guid.NewGuid():N}.tmp"; + var payload = $"v1\t{Environment.ProcessId}\t{DateTimeOffset.UtcNow:O}\n"; + using (var stream = new FileStream( + tempPath, FileMode.CreateNew, FileAccess.Write, FileShare.None, + bufferSize: 4096, FileOptions.WriteThrough)) + { + var bytes = Encoding.UTF8.GetBytes(payload); + stream.Write(bytes); + stream.Flush(flushToDisk: true); + } + TryRestrictFile(tempPath); + File.Move(tempPath, path, overwrite: true); + tempPath = null; + return true; + } + catch (Exception ex) + { + error = ex.Message; + return false; + } + finally + { + if (tempPath != null) + try { File.Delete(tempPath); } catch { } + } + } + + internal static void Clear(string filePath) + { + try { File.Delete(MarkerPath(filePath)); } catch { } + } + + internal static bool TryConsume(string filePath) + { + var path = MarkerPath(filePath); + if (!File.Exists(path)) return false; + try { File.Delete(path); } catch { /* repeat the warning next time */ } + return true; + } + + private static string MarkerPath(string filePath) + { + var canonical = PathIdentity.Canonical(filePath); + if (OperatingSystem.IsWindows() || OperatingSystem.IsMacOS()) + canonical = canonical.ToUpperInvariant(); + var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(canonical)))[..24]; + var root = Path.Combine(UpdateChecker.ConfigDir, "resident-recovery"); + return Path.Combine(root, $"{hash}.dirty"); + } + + private static void TryRestrictDirectory(string path) + { + if (OperatingSystem.IsWindows()) return; + try + { + File.SetUnixFileMode(path, + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + } + catch { } + } + + private static void TryRestrictFile(string path) + { + if (OperatingSystem.IsWindows()) return; + try { File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite); } + catch { } + } +} diff --git a/src/officecli/ResidentServer.cs b/src/officecli/ResidentServer.cs index bb9cdbeca..42d10a289 100644 --- a/src/officecli/ResidentServer.cs +++ b/src/officecli/ResidentServer.cs @@ -26,6 +26,11 @@ public class ResidentServer : IDisposable // after a successful _handler.Save(). The idle-autosave watchdog uses it // to skip flushing when nothing changed since the last save. private volatile bool _dirty; + // Narrower than _dirty: true only while user mutations are not durable. + // An interrupted xlsx formula sweep can keep _dirty=true for a later + // housekeeping pass even though the user's edits are already on disk. + private bool _unflushedMutations; + private bool _recoveryMarkerArmed; // Stderr captured during DocumentHandlerFactory.Open (i.e. while the // constructor was building _handler). At that point there's no // per-command Console.SetError scope, so warnings written by plugin @@ -229,6 +234,13 @@ public ResidentServer(string filePath, bool editable = false) finally { Console.SetError(origErr); } var captured = startupErrSink.ToString().TrimEnd('\r', '\n'); if (captured.Length > 0) _startupStderr = captured; + if (ResidentRecoveryMarker.TryConsume(_filePath)) + { + var warning = ResidentRecoveryMarker.WarningMessage(_filePath); + _startupStderr = string.IsNullOrEmpty(_startupStderr) + ? warning + : $"{_startupStderr}{Environment.NewLine}{warning}"; + } } public static string GetPipeName(string filePath) @@ -501,6 +513,9 @@ private async Task TryAutosaveAsync(CancellationToken token) } sw.Stop(); RecordSaveDuration(sw.Elapsed); + // The user's edits are durable after any successful Save, even if + // a yielded formula-cache sweep keeps _dirty set for housekeeping. + MarkMutationsFlushed(); // A sweep interrupted by a command leaves formula caches partially // refreshed on disk. Keeping _dirty=true makes the next idle window // save again — that re-runs the sweep (the handler's Modified gate @@ -798,8 +813,13 @@ private string ProcessRequest(string requestLine) _handler.Save(); sw.Stop(); _dirty = false; + MarkMutationsFlushed(); RecordSaveDuration(sw.Elapsed); } + // Arm before building/writing the response: every mutation the + // client can observe as successful therefore has a durable + // warning marker during the deferred-flush window. + SyncRecoveryMarker(); } finally { @@ -901,6 +921,9 @@ private string ProcessRequest(string requestLine) } catch (Exception ex) { + // A mutating command can fail after changing the in-memory DOM. + // Keep the advisory marker conservative on that path as well. + SyncRecoveryMarker(); // CONSISTENCY(error-wrap): mirror CommandBuilder.WriteError — // surface a friendlier message when an OOXML part is externally // corrupted, instead of the raw "Data at the root level is @@ -1072,6 +1095,40 @@ private void PromoteToEditable() // next save/close/idle-autosave. Set here (the shared mutation prelude) // so single commands and batch alike are tracked. _dirty = true; + _unflushedMutations = true; + } + + private void SyncRecoveryMarker() + { + if (!_unflushedMutations) + { + if (_recoveryMarkerArmed) + ResidentRecoveryMarker.Clear(_filePath); + _recoveryMarkerArmed = false; + return; + } + if (_recoveryMarkerArmed) return; + + if (ResidentRecoveryMarker.TryMark(_filePath, out var error)) + { + _recoveryMarkerArmed = true; + return; + } + + Console.Error.WriteLine( + $"WARNING: could not arm the resident crash-recovery notice ({error}). " + + "Use OFFICECLI_RESIDENT_FLUSH=each if this process may be terminated externally."); + } + + private void MarkMutationsFlushed() + { + _unflushedMutations = false; + // Always retry deletion. A previous process may have consumed the + // marker but failed to remove it (for example because of a transient + // filesystem error); a later successful save must not leave that + // stale warning behind. + ResidentRecoveryMarker.Clear(_filePath); + _recoveryMarkerArmed = false; } private void ExecuteCommand(ResidentRequest request) @@ -1267,6 +1324,7 @@ private void ExecuteBatch(ResidentRequest request) _handler.Save(); swBarrier.Stop(); _dirty = false; + MarkMutationsFlushed(); RecordSaveDuration(swBarrier.Elapsed); } if (hasMutating) PromoteToEditable(); @@ -1339,6 +1397,7 @@ private void ExecuteBatch(ResidentRequest request) wh2.AdoptPendingWholeParts(preBatchWholeParts); } _dirty = false; + MarkMutationsFlushed(); rolledBack = true; } @@ -2577,6 +2636,7 @@ private void ExecuteSave() _handler.Save(); sw.Stop(); _dirty = false; + MarkMutationsFlushed(); RecordSaveDuration(sw.Elapsed); Console.WriteLine($"Saved {Path.GetFileName(_filePath)}"); } @@ -2776,6 +2836,8 @@ private async Task DoShutdownAsync() disposeFailed = true; LogStderr($"Warning: handler dispose error: {ex.Message}"); } + if (!disposeFailed) + MarkMutationsFlushed(); // BUG-BT-R26-2 / BUG-R43: detect data loss. The original probe used // File.Exists(_filePath) post-Dispose — but on macOS, renaming the