Skip to content

Refuse root-config changes the config environment would undo, remove a dropped component's entry last, and release a scanner-held lock ticket - #2801

Merged
kriszyp merged 10 commits into
mainfrom
claude/root-config-env-layers-drop-order
Sep 25, 2026
Merged

kriszyp merged 10 commits into
mainfrom
claude/root-config-env-layers-drop-order

Conversation

@dawsontoth

@dawsontoth dawsontoth commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

A deploy or drop_component whose root config change HARPER_CONFIG or HARPER_SET_CONFIG would undo is now refused with a 409 before anything moves, where it used to report success and then be rewritten by the next config refresh. drop_component now removes the component's root config entry last and puts the tree back if the removal fails, so a failed drop no longer leaves a live component whose package, settings and isolation are gone. And a lock release whose ticket a Windows scanner is holding now completes, instead of leaving the ticket to read as a live holder. These are follow-ups to #2789 and #2796 (harper#2315 step 3), from kriszyp's review of them.

The first gap: with HARPER_SET_CONFIG forcing web.package, an activation of another package wrote its entry, and the refresh that follows the write put the forced package back. The activation still returned success and retired its journal, leaving the new release live under the old entry, so a later install could restore the old package; the same refresh could reintroduce an entry drop_component had just removed. The second: the drop removed the entry before the tree, so a failure to unlink the node_modules link or rename the tree rejected the drop after the entry was already gone. The third: the release read its ticket's owner before removing it, and a read refused by a scanner threw before the release-marker fallback #2796 added could run.

For the human reviewer

  1. An effect the config environment would undo is refused, not published with a warning. HARPER_CONFIG and HARPER_SET_CONFIG reassert every key they name at each start and each config refresh, over the file and over edits to it. The pre-flight and the writer under the lock compose both together over the document they would write, as a refresh applies them, and refuse an effect they contradict: a declared key set to another value, or, for a payload deploy or a drop, a package a variable keeps. A start installs a component from its entry only when the entry names a package, so install options, credentials and settings such as isolated or host a variable keeps for the name reinstall nothing and do not block it; the integration harness passes suite config that way, and isolated-application.test.ts drops such a component. Each key is attributed to the variable that wins it, and the 409 names both the variable and the key. Keys a variable only adds beside the declared ones are no contradiction, so an operator-forced isolated: true stays beside a deploy's package. The alternative was to publish and warn that the environment wins; refusing means env-forced config can block a component's deploys everywhere that variable is set, Fabric included, until the variable changes. It is easy to relax.
  2. HARPER_DEFAULT_CONFIG is caught only after the refresh. At runtime it fills in keys the file lacks, and whether it puts a removed key back depends on its state file: a key it once supplied reads as the user's once removed. Composing it without that state would refuse drops and payload deploys that do stick. So after its refresh the writer re-reads the file and throws a 409 if the effect no longer holds, refusing a document that no longer parses. It checks the file, not this thread's memoized view, because a worker applies the default in memory for keys the main thread would treat as removed by the user. For an activation that throw lands after the commit, so the journal is kept and the component fails closed until the conflict is resolved: a late failure, but a reversible choice.
  3. drop_component removes the entry last, rather than journaling the drop. It refuses what the removal would refuse before anything moves, renames the tree aside with retireComponentDirectory, which flushes both parent directories before the entry's durable removal, and removes the entry. If the removal fails, the tree is renamed back while the file still holds the entry, a document that no longer parses counting as holding it. If the failure came in the refresh after the removal was written, the drop is finished instead. The window this leaves is a crash between the rename and the removal: the entry survives without its tree, the next start reinstalls a package component, and repeating the drop finishes it. The old order's crash left the tree live without its entry, which runs a component without its isolation. Journaling remove like the activation effects would close the window, at the cost of a journal format change.
  4. The node_modules link is now cleanup after the entry, so failing to remove it no longer fails the drop. Its unlink is logged, and the drop completes with a dangling link. kriszyp's suggested check expected that failure to fail the drop with both halves intact. Nothing irreversible follows the link any more, though, and failing a drop whose entry and tree are already gone would report the wrong outcome.
  5. A release whose ownership read is refused goes ahead with the token it holds. Only this acquisition publishes a ticket at a path its token names, so a record that is there but cannot be read (EACCES, EBUSY or EPERM) is still its own. The release then retries the unlink and falls back to the release marker as before. A readable record still gets the lost-ownership check. This rests on the token-named path, and it rides along here because it is the same review's follow-up; it reverts on its own.

How it works

Every root-config effect is now checked against the reasserting env layers twice. It is checked before it is written, where a refusal changes nothing, and it is re-checked in the file after the refresh that follows the write, which catches what composing those layers cannot predict.

flowchart TB
  eff["an effect: set, unset-package or remove"] --> comp["compose HARPER_CONFIG and HARPER_SET_CONFIG<br/>over the document about to be written"]
  comp --> q{"does the effect<br/>still hold?"}
  q -- no --> ref["409 naming the variable and keys:<br/>nothing written, nothing moved"]
  q -- yes --> write["write the entry durably,<br/>then refresh the config"]
  write --> q2{"does the file<br/>still hold it?"}
  q2 -- no --> err["409: the refresh undid it,<br/>e.g. HARPER_DEFAULT_CONFIG"]
  q2 -- yes --> ok["published"]
  classDef quiet fill:#eaf5ea,stroke:#4a8a4a,color:#1d3b1d
  class ref quiet
Loading

A drop now reaches its entry only after the tree is out of the way, and can walk that back. The rename is the one step that moves the live tree, and everything after the entry removal is cleanup.

flowchart TB
  subgraph before["Before"]
    direction LR
    b1["remove the entry"] --> b2["unlink the node_modules link"] --> b3["rename the tree aside"]
    b2 -. "fails" .-> bx["component live,<br/>entry gone"]
  end
  subgraph after["After"]
    direction LR
    a0{"pre-flight"} -- yes --> a1["rename the tree aside"] --> a2["remove the entry<br/>(the commit)"] --> a3["discard the tree,<br/>unlink the link (logged)"]
    a0 -- no --> an["refused,<br/>nothing moved"]
    a2 -. "fails" .-> ab["rename the tree back:<br/>tree and entry intact"]
  end
  before ~~~ after
Loading

Changes

Verification

End to end, a new suite in root-config-activation-effect.test.ts runs kriszyp's check for the first finding. It boots Harper with HARPER_SET_CONFIG forcing a component's package, which is how the harness passes a suite's config. It then deploys a different package, which answers 409 naming HARPER_SET_CONFIG and leaves the release and its entry as they were, and drops the component, which is refused and leaves it whole. The suite passes, 2 of 2, and the three deploy and drop integration files pass 40 of 40, as does isolated-application.test.ts, whose drop of a component a variable configures is what caught the over-strict first version of the drop rule.

Gates, run locally on macOS with Node 24 at c24777a38, with HOME naming a sentinel home, whose boot properties point at a throwaway install, and a fresh short TMPDIR. The last commit's changes ran in the affected suites (347 pass).

Gate This branch main
test:unit:main 5879 pass, 26 fail the same 26 fail
root-config-activation-effect, stage-then-activate, components.test.mjs 40 pass
  • The unit failures are the same 26 by name as on main. They are environment-specific on this Mac: /private/var realpath comparisons, the shell's git-credential environment, git-reference fixtures that fail under a swapped HOME, the uWS UDS adapter, and process-group reclaim ordering. applicationSpawn.test.js is excluded because it wedges locally, on main too.
  • The sentinel install is byte-identical after every run.
  • Formatting, lint, the type check and the design-docs check are clean on every changed file.

Complexity: complicated

Review-Coverage: authored=claude; ran=cursor-composer,gemini,codex; adjudicated=domain; declined=cursor-grok,cursor-kimi,cursor-muse; rounds=3; full=1 @ 34265c6

Human-Review-Need: 3 (decisions: env-contradiction-refuse-vs-warn, drop-allows-env-settings-residue, drop-rename-before-entry, default-config-backstop-only, windows-lock-changes-in-this-branch) @ d25dbaf

dawsontoth and others added 5 commits September 25, 2026 10:03
…e entry last

HARPER_CONFIG and HARPER_SET_CONFIG rewrite every key they name at each
start and config refresh. So a package activation whose entry one of them
contradicted went live under the forced entry, with the journal retired, and
a dropped component's entry came back. The pre-flight now composes those two
variables over the document it would write and refuses, with a 409, an effect
they contradict. The writer repeats the check under the lock, and re-reads the
file after its refresh, throwing if the effect no longer holds.

drop_component removed the entry before the tree, so a tree removal that
failed left the component live with its package, settings and isolation gone
from root config. It now refuses what the removal would refuse before anything
moves, renames the tree aside, and removes the entry last, renaming the tree
back if that fails. The node_modules link and the aside tree are cleanup after
the entry, logged rather than thrown.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…iled drop's tree only with its entry

The pre-flight composed HARPER_CONFIG and HARPER_SET_CONFIG one at a time, so
a deploy of the value HARPER_SET_CONFIG forces was refused because
HARPER_CONFIG named another. They are now composed together, as a refresh
applies them, and each contradicted key is attributed to the variable that
wins it. A key no variable sets is an artifact of composition and is dropped,
which also keeps a key with a dot in it from reading as contradicted. The
check after the refresh answers 409, like the pre-flight.

A drop whose refresh failed after the entry removal was written put the tree
back without its entry. It now restores the tree only while the file still
holds the entry, and otherwise finishes the drop. Both of the tree's renames
are flushed before the entry's removal is made durable.

Also from kriszyp's review of #2796: a release whose ownership read is refused
(EACCES, EBUSY, EPERM, as a Windows scanner holding the ticket without read
sharing causes) never reached the release marker, leaving the ticket to read
as a live holder once the scanner let go. Only this acquisition publishes a
ticket at a path its token names, so the release now proceeds with the token
it holds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…loy and a drop

Boots Harper with HARPER_SET_CONFIG forcing a component's package, which is how
the integration harness passes a suite's config, then deploys a different
package (409, release and entry unchanged) and drops the component (409, left
whole).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… on Windows

libuv's UV_FS_O_EXLOCK opens a file with no sharing, the handle of a scanner
holding the ticket without read or delete sharing. The release publishes the
marker, and once the handle closes the next holder acquires.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…iled compensation

hasRootConfigEntry relied on parseYamlDoc throwing, but it records YAML errors
instead, so a document broken between the drop's pre-flight and its removal
could read as having no entry and have the tree discarded. A document with
errors now counts as still holding the entry. The retire step's compensating
rename, after a failed flush, is now flushed and logged with the aside path
when it fails, rather than swallowed. The env-layer attribution composes each
variable once, not once per contradicted key.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@dawsontoth dawsontoth added this to the v5.3 milestone Sep 25, 2026

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request refactors the component drop and deployment process to ensure transactional safety and prevent inconsistent states during failures. It introduces a reversible directory retirement mechanism (retireComponentDirectory) and adds pre-flight checks to refuse configuration changes that would be overridden by environment variables like HARPER_CONFIG or HARPER_SET_CONFIG. Additionally, it improves lock release resilience when lock files cannot be read. The review feedback suggests using optional chaining (error?.code) when handling caught errors in components/operations.js to safely check error properties and prevent secondary TypeErrors.

Comment thread components/operations.js
dawsontoth and others added 3 commits September 25, 2026 10:32
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ndows claim read that is in flux

A drop was refused whenever HARPER_CONFIG or HARPER_SET_CONFIG named any key of
the component, but settings such as isolated or host that a variable keeps for
the name install nothing: the next start has no package to reinstall it from.
The integration harness passes a suite's component config that way, and
isolated-application.test.ts's drop failed on it. A drop, like a payload deploy,
now counts only package, install and credentials as contradicted.

On Windows, a scan that reads a claim another contender is deleting (delete
pending) or a scanner holds gets EPERM or EBUSY, which failed the acquisition;
rootConfigPublication.test.js's racing writers hit it on the Windows gate. The
acquisition now repeats such a scan for up to two seconds before failing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…dows scan retry by the caller

A start installs a component from its entry only when the entry names a
package, so install options or credentials a variable keeps for a dropped
name reinstall nothing. A payload deploy and a drop now count only package
as contradicted.

The patient Windows scan retried for two seconds whatever the caller's
budget, eight times the boot recovery probe's 250 ms. It now gives up at
the caller's deadline too.

The check after the refresh refuses a document that does not parse rather
than reading what the parser recovered. The retire and restore messages
now say whether the rename or only its flush failed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@dawsontoth
dawsontoth marked this pull request as ready for review September 25, 2026 15:29
@claude

claude Bot commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

Reviewed; no blockers found.

dawsontoth and others added 2 commits September 25, 2026 14:15
main's #2803 retries an EPERM claim read inside readOwner, which fixes the
Windows race this branch had worked around with a scan-level retry; that
retry is dropped in favour of it. The release's fallback for a record it
cannot read stays, now behind readOwner's own retry. The design note keeps
both #2803's paragraph and this branch's marker-owner sentence.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@kriszyp kriszyp left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Reviewed with Codex

if (!permissionsEnforced()) return this.skip();
const entry = liveComponent('drop-stuck');
// A directory moved under another parent must itself be writable, for its `..` entry.
fs.chmodSync(componentDir, 0o555);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On POSIX, renaming a directory requires write permission on its source and destination parent directories; making the source directory 0o555 does not block the rename at components/Application.ts:3496. On non-root Linux, this drop can proceed instead of producing the EACCES/EPERM asserted below. Could this test make the components root unwritable after pre-creating the lock and aside directories, then restore its mode in finally, or use another deterministic rename failure? I could not run the file-writing test in this read-only checkout; a focused non-root Linux run would confirm the failure and the replacement setup.

@kriszyp
kriszyp merged commit 14b1fd5 into main Sep 25, 2026
51 checks passed
@kriszyp
kriszyp deleted the claude/root-config-env-layers-drop-order branch September 25, 2026 18:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants