Skip to content

Reclaim leaked CowTree pages, and prove a failed MSI upgrade rolls back exactly - #1634

Merged
christianparpart merged 10 commits into
masterfrom
claude/1624-1629-leaks-and-rollback
Oct 6, 2026
Merged

christianparpart merged 10 commits into
masterfrom
claude/1624-1629-leaks-and-rollback

Conversation

@christianparpart

@christianparpart christianparpart commented Oct 6, 2026 •

Copy link
Copy Markdown
Member

Closes #1624. Closes #1629.

Ten commits. The first three are the concerns below: storage, the MSI rollback-state defect, and the CI step. The rest are what CI and review found along the way:

  • the step's controls, made able to fail;
  • a custom-root uninstall defect and the wording of its rule (section 4);
  • a Restart Manager defect that predates this PR, in three commits: the fix, the diagnostics that proved its real cause, and the second fix built on that proof (section 5).

1. storage: a reopened store reclaims every page it leaked (#1624)

A leaked page counts against --cache-disk / --storage-max-disk forever. Each leak is fixed where it happens:

Leak Fix
A crash: pages added to the file after the last durable meta Meta layout version 2 records the file's data-page count, and recovery frees every page past it. Version-1 stores still open.
Fsync / None durability: no free list was ever written Every meta write goes through one CommitMetaLocked, which writes the list, the meta, the tail cut and the release of pending frees. The startup refusal of a disk budget with a non-batched durability is deleted.
A clean close: the last commit's frees reached no list The destructor restates the last durable meta under the next txnId, with the list. Only at close; the reason is stated in code and rules.

How it was checked:

2. msi: discard the rollback state only once the install has succeeded

The bug: FastCacheDiscardRollbackState was a deferred action. A failure after it rolled back with the saved registrations already deleted, so the exact restores did nothing.

The fix:

  • Commit phase: the discard is now a commit action, which runs only after the script succeeds.
  • A test hook: the shipped package gains FastCacheFailForTest, a late failure gated on the secure property FASTCACHE_FAIL_FOR_TEST=1, so CI tests the package that ships.
  • A static guard: check-msi-custom-actions.ps1 step 11 allows a rollback-state delete only in the commit phase, requires a scheduled commit discard to exist, and states its blind spot.

3. ci: a deliberately failed upgrade restores the previous installation exactly (#1629)

A new step in Package (Windows .msi):

  • The scenario: N is installed fastcached-only into a custom root, then upgraded to N+1 with the node added and the failure switched on.

  • What it checks: everything must equal N afterwards: both services' full registry keys, their running state, the installer values, both firewall groups, the installed product, the absence of the rollback key, and the binary.

  • Controls: patched copies of the MSI, each read back before it is trusted:

    • A: every restore of fastcached's registration off, which must go red;
    • B: only the twin restore off, which must restore exactly (the exact restore alone suffices);
    • C: B plus the discard moved back into the script, which must go red.

    The registration carries an extra flag, so a re-registration from defaults cannot pass for an exact restore.

  • Discrimination: a successful run of the same upgrade must change every field the failed one had to keep.

4. msi: a maintenance transaction finds the product's own install root

CPack resolves INSTALL_ROOT from the previous product only during an upgrade. A repair, a modify or an uninstall of a product installed into a custom folder therefore fell back to the default folder. The uninstall then ran actions against binaries that were not there, and left FastCached registered. The package now reads its own InstallLocation and restates it as INSTALL_ROOT before costing, in maintenance only. check-msi-custom-actions.ps1 step 12 pins it.

5. msi, ci: Restart Manager cannot restart fastcached into the node's port

CI's first run failed an older step, "an upgrade deselecting fastcached", with 1603: the node could not bind 6674 because FastCached held it.

  • Measured, with diagnostics added for the purpose: in the upgrade from 0.3.0, the old package's removal opens Restart Manager. RemoveExistingProducts runs that removal inside our transaction, and the 0.3.0 package never turned Restart Manager off.
    • That session shut FastCached down at 13:52:45.
    • Windows Installer started it again by name at 13:53:21, after the install had completed and after every action of ours.
    • FastCached then failed on 6674, which the node held, and its recovery policy kept restarting it.
  • Our own package sets MSIRESTARTMANAGERCONTROL=Disable. It cannot reach the old package's removal: nothing of ours may run before RemoveExistingProducts (Error 2613).
  • The fix: beside the node, FastCached is now disabled, not manual, through a checked action that applies only to a registered service.
    • A disabled service fails to start before any process exists, so there is no crash loop and no race for 6674. An accidental net start cannot cause one either.
    • Removing the node registers FastCached as auto again, and a failed transaction restores the start type it found.
    • This changes the service table from Zero-config Windows office fleet #1600. Going back to manual is a one-row change.
    • CI measured it: the upgrade from 0.3.0 logs RESTART MANAGER: Failed while restarting applications. Error: 352, exits 0, and FastCached is never started.
    • install.md documents the one residual case: an upgrade from 0.3.0 with fastcached, no node, and FASTCACHED_START_SERVICE=0.
  • Behavioural guard: Invoke-Msiexec judges every one of build.yml's 31 transactions twice, while msiexec runs and again once it has settled (at least 10 s after exit), and the next transaction refuses to begin until it has.
    • It watches both services and reads 7031/7034, plus 7036 where the host writes it.
    • Any termination fails the step, and so does a start of a service the transaction leaves not running.
    • A call that states no expectation is refused before msiexec starts.
    • On a refusal it prints every RESTART MANAGER line and the verbose log's actions around each finding.
  • Static guards: steps 13 and 14 of check-msi-custom-actions.ps1, and check-wix-service-table.cmake.
  • Also: an AGENT.md tripwire, and doc-subject-checks.sh now makes --source-dir absolute (a relative one failed three of its seventeen checks).

Verification (local)

The gate and MSVC runs below covered the C++ tree. No commit after the first touches src/; those commits change only packaging, scripts, the workflow and the rules.

  • scripts/local-gate.sh (WSL): PASSED. clang-debug ran 6631 tests with 0 failed, and gcc-release ran 6630 tests with 0 failed. The pinned clang-format and clang-tidy (22.1.8) report no findings.
  • MSVC cl-release (Windows): the build is clean (warnings are errors), and the full ctest run passes 6571 tests with 0 failed.
  • Static checks: the MSI module self-test (171 cases), check-msi-custom-actions.ps1 (14 steps), msi-custom-action-commands and check-wix-service-table all pass.
  • MSI behaviour: no MSI is installed on the development machine, so CI is the real run. On head d1c1cc0e4, Package (Windows .msi) passes. That job runs the failed-upgrade step with its controls, the upgrade from 0.3.0, and all 31 judged transactions.
  • CI: all 30 checks pass on d1c1cc0e4; the two skips are release-only. Windows-cl-release was re-run once because GitHub's Actions cache was over its account egress limit (HTTP 503 at sccache startup), not for anything in this PR.

Since the disk budget became the store's page footprint, a page the store leaked
counted against the budget and evicted a live entry for good. Leaks came from
three places, and each is now fixed where it happens:

- A crash: pages added to the file after the last durable meta were in no tree
  and no list, and recovery marked them live. The meta page now records the
  file's data-page count (meta layout version 2), and recovery frees every page
  past it. A version-1 meta still opens and reclaims nothing; its slot is
  rewritten as version 2 at the next meta write.
- Fsync and None durability: only a batched flush wrote a free list, so every
  restart marked every free page live. Every meta write, under every durability,
  now goes through one FilePageStore::CommitMetaLocked that writes the list, then
  the meta, then truncates the free tail and releases the pending frees. Under
  Fsync, an extra fsync is paid only when a list page was written. The startup
  refusal of --storage-max-disk with a non-batched durability is deleted.
- A clean close: CowTree frees replaced pages after the meta write, so the last
  commit's frees reached no list. The destructor now restates the last durable
  meta under the next txnId, with the list. Only the destructor does this,
  because a tree still running would reuse that txnId.

The tests reproduce each leak and assert the reopened PagesInUse. Each fix was
switched off in turn and its own cases went red; the Fsync/None restart case
depends on two of them, so it goes red under either. A pre-existing test
assumed one meta write after a damaged-slot recovery. It now asserts the exact
sequence, and it still catches the #726 slot-parity bug. The rules and the
operator docs say what changed, including that a None store can be refused
Corrupt after a power loss. Stores written before this change keep the pages
they already lost. The fsync-failure window found on the way is #1633.

Closes #1624

Signed-off-by: Christian Parpart <christian@parpart.family>
FastCacheDiscardRollbackState deleted the saved service registrations as a
deferred action, inside the install script. A failure after it rolled back with
the state already gone, so each exact restore read a missing key and did
nothing. It is now a commit action, which Windows Installer runs only after the
whole script succeeded and never during a rollback. It also runs from
System64Folder now, since reg.exe needs no install root.

The shipped package also gains FastCacheFailForTest: a deferred action that
fails the install last, only when the secure property
FASTCACHE_FAIL_FOR_TEST="1" is set. It exists so that CI tests the package
that ships.

check-msi-custom-actions.ps1 gains step 11. It allows a rollback-state delete
only in the commit phase, with deferred deleters as stated rows, and requires a
scheduled commit-phase discard to exist. It states the spellings it cannot see.
check-wix-service-table pins the new action, its secure property, and its place
as the only action before InstallFinalize.

Signed-off-by: Christian Parpart <christian@parpart.family>
…exactly (#1629)

A new step in the Windows package job:
- installs build N, fastcached-only, into a custom root;
- upgrades to N+1 with the node added and FASTCACHE_FAIL_FOR_TEST=1;
- requires the machine to equal N afterwards: both services' full registry
  keys, their running state, the installer values, both firewall groups, the
  installed product and the rollback key.

Two controls must go red: a copy with the exact restores switched off, and a
copy with the discard moved back into the script. The second is the defect the
previous commit fixes. A final successful run of the same upgrade must change
everything the failed one was required to keep.

MsiServiceTable.psm1 gains the helpers, each driven by its self-test:
- an installation snapshot that waits for a stable service state;
- a pure, multiset comparison that names every difference;
- a control copy whose patch is read back before it is trusted;
- one MSI scalar reader.

A failed firewall read is now an error, not an empty group.

Closes #1629

Signed-off-by: Christian Parpart <christian@parpart.family>
@christianparpart christianparpart added the type/bug Behaves incorrectly against its stated contract label Oct 6, 2026
@github-actions github-actions Bot added area/storage Cache/, CowTree/ - engine, LRU, layered, sharded, compression area/platform Platform/, Config/, Cli/ - service registration, config lookup, the CLI table area/packaging packaging/, CPack, deb/rpm/pkg/msi, installers area/build CMake, presets, toolchains, CI workflows os/windows Specific to Windows (MSVC, clang-cl, Winsock, IOCP) labels Oct 6, 2026
The first CI run measured the subject green: all 10 fields were equal to build
N after the forced failure. But the first control, with the exact restores
switched off, also came back with no difference, so it could not show anything.
Two things made it blind:

- A second rollback action, FastCachedRestoreRegistration, re-registers the
  service on its own. It is inferred to be the restorer, and control A now pins
  that.
- CPack's WiX generator searches the old product's InstallLocation for
  INSTALL_ROOT (FindInstallLocation in properties.wxi, measured), so an upgrade
  keeps N's custom root, and fastcached's ImagePath never changed.

The fix makes N's registration differ from what any re-registration writes.
After every install of N, the step appends one harmless, explicit flag
(--log-level=info, the default level) through Win32_Service.Change and asserts
it. A re-registration rebuilds the command line from its own argv and drops the
flag; only the exact restore, which copies the live key, puts it back.

The controls become three:
- A: every rollback row that rewrites fastcached's registration is off, and the
  result must be red.
- B: only the re-registration is off, and the result must be empty. That proves
  the exact restore works on its own.
- C: B plus the discard moved back into the script, and the result must be red.
  B and C differ only in the discard phase.

The discrimination leg requires only fields a successful upgrade really
changes, and asserts that the custom root is kept.

Signed-off-by: Christian Parpart <christian@parpart.family>
…roduct's own uninstall entry

Uninstalling an install made to a custom INSTALL_ROOT never removed its
services. CPack sets INSTALL_ROOT only by a search of
Uninstall\[WIX_UPGRADE_DETECTED]\InstallLocation, so only during an upgrade.
Every maintenance transaction (repair, feature change, uninstall) fell back to
the default root, so each of the 33 actions that run from INSTALL_ROOT, or name
a binary under it, targeted the wrong path. The uninstall logged it as
Info 1721, ignored it, and removed the files, which left the service registered
with nothing to run.

The new CI step measured it: in the /x of build N, INSTALL_ROOT read
C:\Program Files\fastcached\ while the component directories read the custom
root, and FastCached stayed registered.

Fix: when the product is installed, a RegistrySearch of its own
Uninstall\[ProductCode]\InstallLocation feeds a property-setting action
(type 51) that sets INSTALL_ROOT before CostFinalize, in both sequences. Type 51
rather than SetDirectory, because MsiSetTargetPath is not for maintenance
transactions. One resolved property also fixes the working directories, the PATH
entry and ARPINSTALLLOCATION.

Step 12 of check-msi-custom-actions.ps1 requires that resolver whenever a
scheduled action uses INSTALL_ROOT, and check-wix-service-table pins its rows. A
locally built package (WiX 5.0.2) carries both rows and passes ICE63. The step's
NothingInstalled assertions after every custom-root uninstall prove the
behaviour in CI.

Signed-off-by: Christian Parpart <christian@parpart.family>
…ed, and step 12 states what it pins

Windows Installer forbids changing a directory's target path during a
maintenance transaction, and the restriction covers every method, the type-51
property action included. The resolver complies because it sets INSTALL_ROOT to
the product's own InstallLocation, where the product already is. The rulebook
and the fragment's comment had implied that only type 35 was restricted.

A product installed by a build without the resolver keeps the old behaviour for
its own repair or uninstall until it has been upgraded once. That is accepted,
since there is no back-compat before production ready.

Step 12 now states its exclusion truthfully. It changes the count of actions
judged, never the verdict, because an empty set is itself a failure. It also
pins the resolver's search type, bitness and sequences.

Signed-off-by: Christian Parpart <christian@parpart.family>
… every transaction is judged for what it started

PR 1634's CI failed "an upgrade deselecting fastcached" with 1603: the node
could not bind 6674 because FastCached held it, a service the table had
registered manual and never starts. Windows Installer's Restart Manager put it
there. A silent install always uses Restart Manager: at InstallValidate it
shuts down each service holding a file the transaction replaces, and at the
end of the install it starts that service again, whatever the service table
decided. The upgrade from 0.3.0, with FastCached running, therefore started
FastCached after the table had made it manual and stopped it for the node. It
could not bind the port the node holds and terminated three times before
msiexec returned (System log 7031, not a failure-action restart: the first
said "1 time(s)"). Its 30 s recovery restart then took 6674 while a later
transaction had the node stopped. The row assertion after the upgrade read
Manual and Stopped, between two restarts, so the race decided which run went
red, while the start itself happened on every such upgrade.

Restart Manager also cost the rollback its restart. The ServiceControl rows
(Stop="both" Wait="yes") stop each service before its files are replaced, and
StopServices records a rollback start only for a service it found running.
Restart Manager's shutdown came first, so StopServices found nothing to stop.

The fix is MSIRESTARTMANAGERCONTROL=Disable, one Property row of the fragment,
so the service table and the ServiceControl rows own every stop and start, and
their rollback every restart. Not DisableShutdown, which keeps Restart Manager
detecting files in use under a scope its documentation states conditionally.
Not MSIDISABLERMRESTART=1 alone, which keeps the shutdown at InstallValidate
and with it the lost rollback start. CPack's template and generated sources
define no such property: a package CPack and WixUI built carries no row.

msi-custom-action-commands gains step 13. It requires exactly one Property
row with the value Disable, and refuses a SetProperty of it, which Windows
Installer ignores. wix-service-table pins the row. Both refusals say that
Restart Manager restarts a service the table left stopped. Deleting the row
and setting DisableShutdown or 0 each fail both checks; a SetProperty fails
step 13.

The CI steps "Upgrade from the 0.3.0 MSI, then between two builds of this
one" and "A failed upgrade restores the previous installation exactly" now
judge every transaction for what happened DURING it.
Assert-TransactionServiceEvents refuses an unexpected-termination event (7031,
7034) of either service since the transaction began. It also refuses a
process that a service the row leaves Manual ran under, other than the one it
ran under when the transaction began. Invoke-Msiexec watches both services
while msiexec runs, because current Windows no longer logs 7036 (none among
thousands of service control manager events on Windows 11 26200). Each
verdict is a pure function over records. The self-test drives it over the
0.3.0 upgrade CI ran and over each thing that must not count. It drives the
event parse over a real 7031's binary data, the reader against the live System
log in both directions, and the watch over a real process. 103 cases become
123. No transaction of either step expects a termination, so none is exempted.

The rule is in platform-service-and-config.md beside the service table. It
records the residual: the upgrade from 0.3.0 still runs 0.3.0's own removal,
whose package sets no such property.

AGENT.md carries the rule as a tripwire on the service-table bullet, inside
the line budget (two paragraphs rewrapped; the budget ratchets to 1635).

doc-subject-checks.sh makes --source-dir absolute before any check runs: a
relative directory named a different tree for each check that runs from
elsewhere, and three of the seventeen failed on a good tree.

The judgement of what a transaction did to the services is part of
Invoke-Msiexec. Every call names the service table row the
transaction leaves (-Leaves) or a named expectation with its reason
(-Expect). A call naming neither, both or an unknown one is refused before
msiexec starts. The parameters are not Mandatory, because a missing Mandatory
parameter prompts, and on a CI runner that is a hang rather than a refusal.
All 31 calls in build.yml state one, the steps the first change did not touch
included. Two transactions cannot honestly state a row:
- RollsBack: the failed upgrade, its controls and the refused-pin repair. A
  rollback restarts what it stopped, so no start is judged, and every
  termination still is.
- NodeCannotBind: the step that holds 6674. The node it adds cannot bind, and
  CI measured that refusal logged as 7034.
Assert-ServiceTable judges the same transaction once more, over the window its
stability interval adds.

The start check now covers what an expectation leaves NOT RUNNING (stopped or
unregistered), where it covered what a row leaves Manual. So
DaemonAloneUnstarted and 0.3.0's unstarted node are watched too.

7036 is a third witness. The claim that Windows no longer writes it was
false: PR 1634's runner (Windows Server 2025) logs "entered the running
state", and only this repository's Windows 11 26200 host writes none. The
node's own start is the positive control, and the pass line says which state
held: live, ABSENT, or unchecked. A transaction that starts the node and
logs no 7036 is therefore named rather than read as clean.

The event parse reads a 7045's ServiceName item and a 7036's state. The live
self-test case asserts the filter only, so a host whose newest SCM event is
an install no longer fails it.

A failed CIM poll is counted and named, and msiexec is waited for on every way
out of the watch. The refusal names a cause per finding kind: it blames
Restart Manager for a stray start, not for a termination. Each verbose log's
RESTART MANAGER lines are printed, unjudged.

The rule, the fragment and both checks now say what was MEASURED (FastCached
started at 12:12:47 and crashed three times) apart from what is INFERRED
(that Restart Manager started it). They also say what Disable gives up:
non-service processes holding a replaced file, and the stop margin a
maintenance removal had. The 7031/7034 comment now follows the finite
recovery steps.

Self-test: 123 cases become 144. Ten neuters each go red at their own case:
- terminations ignored;
- the baseline ignored;
- 7036 ignored;
- MayTerminate ignored;
- the watch's catch removed;
- the control answering live;
- neither accepted;
- Invoke-Msiexec unjudged;
- the second judgement removed;
- the start check narrowed back to Manual.
The Invoke-Msiexec case starts its process through a seam, so even a neutered
refusal never starts msiexec.

Signed-off-by: Christian Parpart <christian@parpart.family>
…s and the actions around each finding

PR 1634's CI refused the upgrade from 0.3.0 for a start of FastCached nobody
asked for, and its printout said less than it should have.

Show-MsiLog printed three section headers over EMPTY sections, for a log a
Select-String had read a moment earlier. Its lines went to the OUTPUT stream.
Called from Invoke-TransactionJudgement, whose output Invoke-Msiexec captures
in `Write-Host (Invoke-TransactionJudgement ...)`, they were captured, and the
judgement's throw discarded them; the headers, written to the host, survived.
Every line now goes to the host, and the self-test asserts Show-MsiLog writes
nothing to the output stream, through that same captured shape.

The RESTART MANAGER lines after each transaction were the first four, which
are the outer package's own: they cut the nested removal's session they were
cited for. Every line is printed now, up to a stated cap of 100, and a log
past the cap says how many it cut.

A refused judgement now also prints, from the verbose log:
- every RESTART MANAGER line;
- the lines from 30 s before to 5 s after each finding's time: actions
  starting and ending, service control operations, custom actions, Product:
  lines, a nested product's start (Running product) and Restart Manager.
Windows Installer stamps its lines in the host's local time of day, with no
date, so each finding's UTC time is read on that clock, unstamped lines carry
the previous stamp, and a window across midnight still matches. Each finding
now carries its Time for this.

Self-test: 144 cases become 151. Neutered, each goes red at its own case:
- the tail back on the output stream: the log-on-refusal case;
- a cap of four: the restart-manager-lines case;
- no window on refusal: the log-on-refusal case;
- an action's stamp ignored: the log-clock case;
- no midnight wrap: the lines-around midnight case.

Signed-off-by: Christian Parpart <christian@parpart.family>
… package's removal has Windows Installer restart it after the upgrade

The diagnostics run of PR 1634 MEASURED where the stray start of FastCached
comes from, in the upgrade from 0.3.0:
- this package logged "RESTART MANAGER: Disabled by MSIRESTARTMANAGERCONTROL
  property";
- 0.3.0's removal, which RemoveExistingProducts runs as a nested installation
  with 0.3.0's own properties, opened a Restart Manager session of its own and
  "Successfully shut down all applications in the service's session that held
  files in use";
- the OUTER engine then logged "Failed while restarting applications. Error:
  352". That came after "Installation completed successfully" and after every
  action of this package, commit actions included. Between the two lines,
  FastCached was started and crashed on the port the node holds.

No property of this package reaches that session. RemoveExistingProducts sets
only ProductCode and REMOVE for the removal, and the Upgrade table has no
column for a property. Nothing of ours may run before it (Error 2613), and
moving it before InstallInitialize takes the old product's removal out of the
transaction (#1629). An immediate action runs unelevated under UAC.
DisableAutomaticApplicationShutdown is a machine policy. The restart also comes
after the last action, so nothing of ours can follow it.

So it is answered where it lands. FastCachedDisableForNode leaves FastCached
DISABLED beside the node (`sc config FastCached start= disabled`,
Return="check"), where it was manual. A start of it then fails at the service
control manager: no process, no recovery restart, and no race for 6674 with
the leg that has the node stopped. The upgrade already exited 0 with that 352.
The binary keeps its policy: a bind conflict is an I/O arm, Failed and
restarted by design, so a start that may not run must not be startable.
Removing the node re-applies fastcached auto, and a failed transaction puts
back the start type it found.

The upgrade leg now also asserts the nested shutdown line, so its "no start of
FastCached" judgement cannot pass because no restart was attempted.

The NodeSelected row reads Disabled, and wix-service-table pins the action,
its condition and its place. Both directions were checked: the action set to
demand, and the action unscheduled, each go red.

install.md says what an operator upgrading from 0.3.0 sees: the 352 line,
harmless. The residual is fastcached WITHOUT the node:
FASTCACHED_START_SERVICE=0 on an upgrade from 0.3.0 leaves a FastCached that
was running still running, so stop it first. It also says how to run
FastCached beside the node on purpose. The other pages saying "manual" now say
disabled.

And from the review of the judgement itself:

I-A. Moving the judgement into Invoke-Msiexec made it run at msiexec's EXIT.
The transactions with no Assert-ServiceTable after them lost the window past
the exit that the old placement gave them. Among them is the failed upgrade,
whose rollback restarts fastcached with an unwaited `sc.exe start`, so a bad
restore crashes after the read. Assert-TransactionSettled now judges the last
transaction once more, over a window reaching at least 10 s past the exit,
and waits for the remainder, measured on a monotonic clock. Assert-ServiceTable
runs it after its stability interval. The failed upgrade and every control,
the successful upgrade beside them and the two repairs call it themselves.
It is self-enforcing: Invoke-Msiexec refuses to BEGIN while the previous
transaction has not settled, before anything is started. Only a step's LAST
transaction relies on its call site, and every step ends in an
Assert-ServiceTable. An AST walk over build.yml finds all 31 transactions
settled before the next one, and it flags the one transaction it is shown
with the settle removed.

m-1. The refused-pin repair names its row, NodeSelected, rather than
RollsBack, so the start witness watches fastcached during its rollback. Each
named expectation is MustFail: a transaction stating one that returns 0 is
refused.

m-2. The 7036 control has four states. A node start whose 7036 is missing
while other services' 7036s were read is refused, because the reader or its
matcher failed. NOT SEEN (no 7036 at all) names both causes, and it is refused
once an earlier transaction of the process read the node's 7036.

m-3. A 7036 "stopped" past the one stop the starting process allows is a
start. A start that dies before RUNNING still ends in a stop, and PR 1634's
stray starts logged three stops and no "running".

m-5. The refusal of a call naming neither expectation asserts that the start
seam was never called. The comment gives the real reason the parameters are
not Mandatory.

m-6. NodeCannotBind's reason no longer cites a measurement CI never made.

m-7 is done rather than filed. The diagnostics run showed Windows Installer's
wording ("RESTART MANAGER: Disabled by MSIRESTARTMANAGERCONTROL property", on
the Server 2025 runner). A transaction whose package sets the property to
Disable now must log that line, or it is refused.

The window around a finding reached 30 s back, and its cap of 200 cut the end
of the transaction the findings followed. That is MEASURED on the diagnostics
run: it stopped 27 s early. The window is now 10 s back, and the cap keeps the
LATEST lines.

Self-test: 151 cases become 168. Neutered, each goes red at its own case:
- the unsettled refusal;
- the settle wait;
- MustFail;
- the stops witness;
- the unmatched state;
- the latch;
- the Restart Manager verdict;
- the cap keeping the earliest lines;
- the expectation resolved after the start.

AGENT.md's service-table tripwire names fastcached disabled beside the node,
within the line budget.

Signed-off-by: Christian Parpart <christian@parpart.family>
… is registered, and this build's package must promise Restart Manager off

Review round of the nested Restart Manager fix.

I-1. FastCachedDisableForNode was Return="check" on a registration that
FastCachedInstallService makes Return="ignore" on purpose. A refused or
absent FastCached registration therefore failed the node's whole install with
1603 and rolled it back, and the log named the disable rather than the
refusal. An absent service is no more startable than a disabled one, so only
a registration that EXISTS is disabled now. sc.exe answers 1060 for a service
that does not exist (MEASURED on this host); that one answer ends the action
with 0, and every other answer goes on to the checked disable. The guard
stays fail-closed for a disable that fails on a service that is there.

msi-custom-action-commands step 14 runs the command for real, with every
service name it spells swapped for one nothing registers:
- with that name everywhere it must exit 0;
- with the query naming EventLog and the disable naming the absent service, it
  must exit 1060.
Neither run can change a service. wix-service-table pins the guard. Neutered,
each goes red at its own place:
- an unconditional exit 0 fails the 1060 control;
- the 1060 test inverted fails the absent case;
- the old unguarded command fails both checks.

m-A. install.md no longer claims what a DISABLED start logs. It quotes the
352 line as MEASURED for the case where FastCached could still be started,
and names the client's misleading "Previously shut down applications have
been restarted.".

m-B. A package's Restart Manager promise is no longer skipped silently. This
build's package must set MSIRESTARTMANAGERCONTROL=Disable, or the transaction
is refused. A released package is named with -Released (0.3.0 and the latest
release); it is judged only if it sets the property, and a skip is printed.

m-C. The comments in check-msi-custom-actions, check-wix-service-table and the
fragment now say what was MEASURED: the start came from the OLD package's
nested session, so the property is necessary but not sufficient.

m-D. The start-type search comment names #4 (disabled). The rule says the
binary's re-registration twin degrades disabled to manual, and why that is
accepted.

m-E. install.md now says the next transaction stops an overridden FastCached,
as well as disabling it. It also states the old removal's forced close of a
program holding 0.3.0's files.

Self-test: 168 cases become 171. Neutered, the released switch goes red at
"this build's package without the property".

install.md states the refused restart's log lines as MEASURED with FastCached
disabled: CI's upgrade from 0.3.0 logged "Failed while restarting applications.
Error: 352", exited 0, and FastCached was never started.

Signed-off-by: Christian Parpart <christian@parpart.family>
@christianparpart
christianparpart merged commit 4d6255f into master Oct 6, 2026
61 of 62 checks passed
@christianparpart
christianparpart deleted the claude/1624-1629-leaks-and-rollback branch October 6, 2026 15:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/build CMake, presets, toolchains, CI workflows area/packaging packaging/, CPack, deb/rpm/pkg/msi, installers area/platform Platform/, Config/, Cli/ - service registration, config lookup, the CLI table area/storage Cache/, CowTree/ - engine, LRU, layered, sharded, compression os/windows Specific to Windows (MSVC, clang-cl, Winsock, IOCP) type/bug Behaves incorrectly against its stated contract

Projects

None yet

1 participant