Skip to content
Open
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
2 changes: 2 additions & 0 deletions src/officecli/CommandBuilder.Save.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ private static Command BuildSaveCommand(Option<bool> 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
Expand Down
2 changes: 2 additions & 0 deletions src/officecli/CommandBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
111 changes: 111 additions & 0 deletions src/officecli/Core/ResidentRecoveryMarker.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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.
/// </summary>
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 { }
}
}
62 changes: 62 additions & 0 deletions src/officecli/ResidentServer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
{
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -1267,6 +1324,7 @@ private void ExecuteBatch(ResidentRequest request)
_handler.Save();
swBarrier.Stop();
_dirty = false;
MarkMutationsFlushed();
RecordSaveDuration(swBarrier.Elapsed);
}
if (hasMutating) PromoteToEditable();
Expand Down Expand Up @@ -1339,6 +1397,7 @@ private void ExecuteBatch(ResidentRequest request)
wh2.AdoptPendingWholeParts(preBatchWholeParts);
}
_dirty = false;
MarkMutationsFlushed();
rolledBack = true;
}

Expand Down Expand Up @@ -2577,6 +2636,7 @@ private void ExecuteSave()
_handler.Save();
sw.Stop();
_dirty = false;
MarkMutationsFlushed();
RecordSaveDuration(sw.Elapsed);
Console.WriteLine($"Saved {Path.GetFileName(_filePath)}");
}
Expand Down Expand Up @@ -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
Expand Down