Skip to content
Merged
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
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,29 @@ is not part of this repository.
new snapshot or put the comparison away no longer takes the middle of the window back - the line at
the foot says it finished and was not shown. Saving a snapshot no longer closes a form for a new
snapshot opened while the first one was being read.
- A query with a pattern that only the slower regular expression engine can run - a lookahead or a
backreference - now spends about a fifth of a second in it over the whole list, and the entries
left after that are reported as never checked. Each entry used to have fifty milliseconds of its
own, so such a pattern could hold `bws list` and the window still for over half a minute.
- In the window, a service that stops or starts again in another process no longer shows the memory
of the process it had before. A stopped service shows no memory in use, and the panel reads the new
process's figure, as does the memory column when it is shown.
- After a plan run from the window, the change history - the view and the section in the panel - is
read again once the System log has had time to record the change, about two seconds later. It used
to stay as it was until F5, without the change the plan had just made.
- The change history no longer says the System log "has lost nothing since" its oldest record: nothing
about a log shows it was written without a gap. A log set to be archived when full now says that
older events may be in archive files, which are not read, instead of being said to overwrite its
oldest events. `bws history --json` names the log's mode in a new `mode` field at the end of `log`:
`Circular`, `AutoBackup` or `Retain`, as `Get-WinEvent -ListLog` names it.
- *Not by SYSTEM* in the window's change history no longer includes changes the log recorded without
an account, which may have been made by SYSTEM. They stay under *All changes*.
- The window's change history sorts by when a change happened rather than by the time as written on
this machine, so the hour the clocks go back is in order. The exported file writes each time with its
offset from UTC, so two changes in that hour no longer look like one moment.
- A file whose signature is in an SHA-1 catalogue that Windows finds but cannot verify is now reported
as a signature that could not be read, with Windows' error number. It used to be reported as not
signed. A file in no catalogue, or a machine that cannot check SHA-1 catalogues, is still not signed.
- `bws kill`, and Force stop and Force restart in the window, now ask the other services living in the
same process to stop in an order Windows accepts: a service another of them needs is asked after
it. They used to be asked in the order of the list, so a service another one still needed was
Expand Down
9 changes: 7 additions & 2 deletions src/Bws.Cli/HistoryJson.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ internal static string Render(LogCoverage log, IReadOnlyList<ServiceChange> chan
ArgumentNullException.ThrowIfNull(changes);

var document = new HistoryDocument(
new LogDocument(log.Name, log.ReachesBack, log.Overwritten, log.Full, Cleared(log.Cleared)),
new LogDocument(log.Name, log.ReachesBack, log.Overwritten, log.Full, Cleared(log.Cleared), log.Mode.ToString()),
[.. changes.Select(Change)]);

return AsciiJson.Serialize(document, Options);
Expand Down Expand Up @@ -61,7 +61,12 @@ internal static string Render(LogCoverage log, IReadOnlyList<ServiceChange> chan
// The order of the members below IS the order of the keys in the document.
private sealed record HistoryDocument(LogDocument Log, IReadOnlyList<ChangeDocument> Changes);

private sealed record LogDocument(string Name, DateTimeOffset? ReachesBack, bool Overwritten, bool Full, ClearedDocument? Cleared);
/// <param name="Mode">
/// What Windows does at the size limit, spelled as <see cref="LogMode"/> spells it - added 2026-10-27
/// (logic report H02) and LAST, so the document only grew at its end.
/// </param>
private sealed record LogDocument(
string Name, DateTimeOffset? ReachesBack, bool Overwritten, bool Full, ClearedDocument? Cleared, string Mode);

private sealed record ClearedDocument(DateTimeOffset? At, ActorDocument? By);

Expand Down
18 changes: 13 additions & 5 deletions src/Bws.Cli/HistoryText.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,13 @@ internal static IReadOnlyList<string> Admissions(ChangeHistory history, IReadOnl
lines.Add(Texts.Of("cli.history.log.full"));
}

// An archived log keeps its past in other files whatever its size is now, so this is said every
// time rather than at the limit - H02, 2026-10-27.
if (history.Log.Mode == LogMode.AutoBackup)
{
lines.Add(Texts.Of("cli.history.log.autoBackup"));
}

if (serviceName is null && history.HasInstallations)
{
lines.Add(Texts.Of("cli.history.installedUnderDisplayName"));
Expand Down Expand Up @@ -107,8 +114,11 @@ internal static string Render(IReadOnlyList<ServiceChange> changes)
}

/// <summary>
/// How far back the log reaches, as the first sentence. Three shapes and one for an empty log, so
/// that "has lost nothing since" is said only when nothing says otherwise.
/// How far back the log reaches, as the first sentence - and only that.
///
/// <b>"And has lost nothing since" went on 2026-10-27</b> (logic report H02): it was said whenever the
/// log was neither at its limit nor full, and neither says the log was written to without a gap - a
/// stopped log service, a log that stood full until somebody made it larger, an archived one.
/// </summary>
private static string Reach(LogCoverage log)
{
Expand All @@ -124,9 +134,7 @@ private static string Reach(LogCoverage log)
: Texts.Of("cli.history.log.cleared", When(oldest), Who(cleared.By));
}

return log.Overwritten || log.Full
? Texts.Of("cli.history.log.reaches", When(oldest))
: Texts.Of("cli.history.log.since", When(oldest));
return Texts.Of("cli.history.log.reaches", When(oldest));
}

/// <summary>
Expand Down
2 changes: 1 addition & 1 deletion src/Bws.Cli/Resources/cli.en.json
Original file line number Diff line number Diff line change
Expand Up @@ -242,13 +242,13 @@
"cli.show.requiredPrivileges": "Required privileges",
"cli.show.securityDescriptor": "Security descriptor",
"cli.show.noSuchEntry": "There is no entry called {0}.",
"cli.history.log.since": "The System log reaches back to {0} and has lost nothing since.",
"cli.history.log.reaches": "The System log reaches back to {0}.",
"cli.history.log.cleared": "The System log reaches back to {0}, when {1} cleared it.",
"cli.history.log.clearedBySomebody": "The System log reaches back to {0}, when it was cleared.",
"cli.history.log.empty": "The System log is empty.",
"cli.history.log.overwritten": "It is at its size limit, so Windows overwrites its oldest events and anything older is gone.",
"cli.history.log.full": "It is full and set to keep what it has, so Windows writes no new events into it and the latest changes are missing.",
"cli.history.log.autoBackup": "It is set to be archived when full, so older events may be in archive files beside it, which bws does not read.",
"cli.history.installedUnderDisplayName": "An installation shows the display name in quotes, because Windows records it without the service name.",
"cli.history.installationsNotByName": "Installations are not listed for one name, because Windows records them without the service name. bws history lists them all.",
"cli.history.unnamed.one": "{0} start type change does not name its entry, so it is not listed here.",
Expand Down
49 changes: 43 additions & 6 deletions src/Bws.Core/History/ChangeHistory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,23 +24,54 @@ public sealed record LogCoverage
/// <summary>The channel, which is <c>System</c> for everything this reads today.</summary>
public required string Name { get; init; }

/// <summary>When the oldest record in the log was written. Null for an empty log.</summary>
/// <summary>
/// When the oldest record in the log was written. Null for an empty log.
///
/// <b>Not a promise that nothing is missing since</b> - logic report H02, 2026-10-27. Nothing about
/// a log says it was written to without a gap: the log service can have been stopped, a log set to
/// keep what it has can have stood full for a week before somebody made it larger, and an archived
/// log keeps its past in other files.
/// </summary>
public required DateTimeOffset? ReachesBack { get; init; }

/// <summary>
/// The log has no room left and Windows makes room by throwing its oldest events away, so the
/// past it covers moves forward as new events arrive.
/// past it covers moves forward as new events arrive. Only ever true for <see cref="LogMode.Circular"/>.
/// </summary>
public required bool Overwritten { get; init; }

/// <summary>
/// Windows says the log is full. With a log set never to overwrite, that means NEW events are not
/// being written at all - the recent end of the history is the one missing.
/// being written at all - the recent end of the history is the one missing. Windows documents it as
/// always false for the two other modes, so it only speaks for <see cref="LogMode.Retain"/>.
/// </summary>
public required bool Full { get; init; }

/// <summary>Set when the oldest record says the log was cleared, and by whom.</summary>
public Clearing? Cleared { get; init; }

/// <summary>What Windows does when the log reaches its size limit - read, not inferred (H02).</summary>
public required LogMode Mode { get; init; }
}

/// <summary>
/// What Windows does when a log reaches its size limit - since 2026-10-27, logic report H02. Named as
/// <c>Get-WinEvent -ListLog</c> names it in its LogMode column, the words an administrator already reads.
///
/// <b>Read from the channel's configuration, not inferred from its state.</b> Until that day the size and
/// <c>EvtLogFull</c> stood in for it, and Windows documents that flag as always false for two of these
/// three - so an archived log at its limit was said to be overwriting its oldest events.
/// </summary>
public enum LogMode
{
/// <summary>The oldest events are overwritten - retention off, the default for the System log.</summary>
Circular,

/// <summary>The full file is put aside as Archive-System-*.evtx and a new one begun - nothing is overwritten.</summary>
AutoBackup,

/// <summary>Nothing is overwritten and nothing is put aside, so a full log takes no new events.</summary>
Retain
}

/// <summary>
Expand All @@ -49,7 +80,8 @@ public sealed record LogCoverage
/// <param name="FileSize">The size of the log file now, in bytes.</param>
/// <param name="MaximumSize">The size the channel is configured never to grow beyond, in bytes.</param>
/// <param name="Full">Whether Windows reports the log as full.</param>
public sealed record LogFacts(ulong FileSize, ulong MaximumSize, bool Full)
/// <param name="Mode">What the channel is configured to do at its size limit.</param>
public sealed record LogFacts(ulong FileSize, ulong MaximumSize, bool Full, LogMode Mode)
{
/// <summary>
/// How much a log file grows at a time. The file is a header followed by chunks of this size, and
Expand Down Expand Up @@ -108,6 +140,10 @@ public IReadOnlyList<ServiceChange> Of(string serviceName) =>
/// <b>Overwritten and full exclude each other</b>: a full log is one that will not overwrite, and a
/// log that overwrites is never reported full - on the owner's machine the Security log sat at its
/// limit, overwriting, and reported not full.
///
/// <b>Overwritten only for a circular log, since 2026-10-27 (H02).</b> It was "not full and at the
/// limit", which also fits an archived log at its limit - Windows puts that file aside rather than
/// overwriting it, and reports it never full.
/// </summary>
public static LogCoverage Cover(string name, LogFacts facts, DateTimeOffset? oldest, Clearing? cleared)
{
Expand All @@ -117,9 +153,10 @@ public static LogCoverage Cover(string name, LogFacts facts, DateTimeOffset? old
{
Name = name,
ReachesBack = oldest,
Overwritten = !facts.Full && facts.AtSizeLimit,
Overwritten = facts.Mode == LogMode.Circular && facts.AtSizeLimit,
Full = facts.Full,
Cleared = cleared
Cleared = cleared,
Mode = facts.Mode
};
}
}
53 changes: 42 additions & 11 deletions src/Bws.Core/History/WindowsChangeHistoryReader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -49,13 +49,15 @@ public ChangeHistory Read()

// The oldest record says how far back the log reaches, and - when it is the event Windows
// writes after emptying a log - who emptied it.
var oldest = Rendered("*", newestFirst: false, limit: 1);
var oldest = Rendered("*", newestFirst: false, limit: 1, xml => xml);

var changes = Rendered(Changes, newestFirst: true, limit: int.MaxValue)
.Select(xml => ChangeEvents.Change(xml)
?? throw new InvalidDataException(
"The System log answered a query for events 7040 and 7045 with an event that is neither."))
.ToList();
// EACH EVENT PARSED AS IT IS RENDERED, since 2026-10-27 - logic report C02, owner's decision "parse
// on the fly". Until then every event was kept as its XML in one list and parsed afterwards, so a
// large log held all of its text and all of its changes at once. The range and the absence of a
// limit are unchanged, by the same decision: only the text goes once it has been read.
var changes = Rendered(Changes, newestFirst: true, limit: int.MaxValue, xml => ChangeEvents.Change(xml)
?? throw new InvalidDataException(
"The System log answered a query for events 7040 and 7045 with an event that is neither."));

var cleared = oldest.Count == 0 ? null : ChangeEvents.ClearingOf(oldest[0], Channel);
var reach = oldest.Count == 0 ? null : ChangeEvents.TimeOf(oldest[0]);
Expand Down Expand Up @@ -98,7 +100,35 @@ private static LogFacts Facts()

Expect(maximum, EVT_VARIANT_TYPE.EvtVarTypeUInt64, "size limit");

return new LogFacts(size, maximum.UInt64Val, full);
return new LogFacts(size, maximum.UInt64Val, full, Mode(channel));
}

/// <summary>
/// What the channel does at its size limit - H02, 2026-10-27. Microsoft's channel schema: retention true
/// is a sequential log, false a circular one, and autoBackup may only be true with retention, when the
/// full file is put aside as an archive. Autobackup without retention is not a shape Windows accepts, so
/// such an answer is read as what retention alone says - circular.
/// </summary>
private static LogMode Mode(SafeHandle channel)
{
var retention = Configured(channel, EVT_CHANNEL_CONFIG_PROPERTY_ID.EvtChannelLoggingConfigRetention, "retention");
var autoBackup = Configured(channel, EVT_CHANNEL_CONFIG_PROPERTY_ID.EvtChannelLoggingConfigAutoBackup, "automatic backup");

return !retention ? LogMode.Circular
: autoBackup ? LogMode.AutoBackup
: LogMode.Retain;
}

private static bool Configured(SafeHandle channel, EVT_CHANNEL_CONFIG_PROPERTY_ID property, string what)
{
if (!PInvoke.EvtGetChannelConfigProperty(channel, property, 0, out EVT_VARIANT value, out _))
{
throw new Win32Exception(Marshal.GetLastWin32Error(), $"Could not read the {what} setting of the System event log.");
}

Expect(value, EVT_VARIANT_TYPE.EvtVarTypeBoolean, what);

return value.BooleanVal;
}

private static EVT_VARIANT Property(SafeHandle log, EVT_LOG_PROPERTY_ID property, EVT_VARIANT_TYPE type)
Expand Down Expand Up @@ -127,9 +157,10 @@ private static void Expect(EVT_VARIANT value, EVT_VARIANT_TYPE type, string what
}

/// <summary>
/// The events a query matches, rendered as XML, newest or oldest first.
/// The events a query matches, newest or oldest first, each rendered as XML and handed to
/// <paramref name="parse"/> at once - the text of one event is all that is held at a time.
/// </summary>
private static List<string> Rendered(string query, bool newestFirst, int limit)
private static List<T> Rendered<T>(string query, bool newestFirst, int limit, Func<string, T> parse)
{
var direction = newestFirst ? EVT_QUERY_FLAGS.EvtQueryReverseDirection : EVT_QUERY_FLAGS.EvtQueryForwardDirection;

Expand All @@ -140,7 +171,7 @@ private static List<string> Rendered(string query, bool newestFirst, int limit)
throw new Win32Exception(Marshal.GetLastWin32Error(), "Could not query the System event log.");
}

var found = new List<string>();
var found = new List<T>();
var handles = new nint[Batch];
var renderer = new Renderer();

Expand All @@ -152,7 +183,7 @@ private static List<string> Rendered(string query, bool newestFirst, int limit)

try
{
found.AddRange(events.Take(limit - found.Count).Select(renderer.Xml));
found.AddRange(events.Take(limit - found.Count).Select(one => parse(renderer.Xml(one))));
}
finally
{
Expand Down
Loading
Loading