Repository navigation
Refuse root-config changes the config environment would undo, remove a dropped component's entry last, and release a scanner-held lock ticket - #2801
Conversation
…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>
There was a problem hiding this comment.
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.
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>
|
Reviewed; no blockers found. |
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>
…de/root-config-env-layers-drop-order
| 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); |
There was a problem hiding this comment.
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.
A deploy or
drop_componentwhose root config changeHARPER_CONFIGorHARPER_SET_CONFIGwould 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_componentnow 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_CONFIGforcingweb.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 entrydrop_componenthad just removed. The second: the drop removed the entry before the tree, so a failure to unlink thenode_moduleslink 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
HARPER_CONFIGandHARPER_SET_CONFIGreassert 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, apackagea variable keeps. A start installs a component from its entry only when the entry names a package, soinstalloptions,credentialsand settings such asisolatedorhosta variable keeps for the name reinstall nothing and do not block it; the integration harness passes suite config that way, andisolated-application.test.tsdrops 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-forcedisolated: truestays beside a deploy'spackage. 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.HARPER_DEFAULT_CONFIGis 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.drop_componentremoves the entry last, rather than journaling the drop. It refuses what the removal would refuse before anything moves, renames the tree aside withretireComponentDirectory, 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. Journalingremovelike the activation effects would close the window, at the cost of a journal format change.node_moduleslink 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.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 quietA 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 ~~~ afterChanges
components/rootConfigPublication.ts: the env-layer check, what contradicts an effect and which variable sets a key, run by the pre-flight and the writer; the file check after the refresh; andhasRootConfigEntryfor the drop.config/harperConfigEnvVars.ts:REASSERTING_CONFIG_ENV_VARSandcomposeReassertedEnvConfig, which sharescomposeConfigFromEnv's composition loop, so directive leaves and the base file's empty objects compose the same way.components/Application.ts:retireComponentDirectorymoves a tree aside reversibly and durably, withrestore()and a best-effortdiscard().dropComponentDirectoryis retire plus discard, for the legacyappsdrop.components/operations.js:drop_component's new order, with the tree discarded and the link removed after the entry.components/componentPreparationLock.ts: the release's fallback for a record it cannot read. The Windows race where a claim read meets another contender's unlink is Retry a lock claim read that Windows refuses mid-unlink instead of failing the acquire #2803's, which main now has.components/DESIGN.md: theremoveeffect's order and crash window, the env-layer rule, and the release marker's owner, when a scanner hides the record.Verification
End to end, a new suite in
root-config-activation-effect.test.tsruns kriszyp's check for the first finding. It boots Harper withHARPER_SET_CONFIGforcing a component's package, which is how the harness passes a suite'sconfig. It then deploys a different package, which answers 409 namingHARPER_SET_CONFIGand 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 doesisolated-application.test.ts, whose drop of a component a variable configures is what caught the over-strict first version of the drop rule.rootConfigPublication.test.jscovers the refusal for each effect kind and both variables, a drop that neither settings nor install options and credentials without a package a variable keeps can block, a keyHARPER_SET_CONFIGalready sets to the declared value, a key a variable only adds, and the file check after the refresh, both a contradiction and a document that stops parsing. For the drop it covers a tree that cannot be moved, an entry that cannot be removed, which puts the tree back, a refresh that fails after the removal, which finishes the drop, a document that stops parsing, which keeps the tree, and a link that cannot be removed, which no longer fails the drop.componentPreparationLock.test.jsreleases a ticket whose record cannot be read at release, which is kriszyp's check for the third finding, with a mode the owner cannot read standing in for the scanner on POSIX. On macOS it publishes the marker when the record can be neither read nor removed, and the next holder then acquires. On Windows, a handle opened with libuv'sUV_FS_O_EXLOCK, which shares nothing, is held through the release: the release publishes the marker, and once the handle closes the next holder acquires, which is the Windows check kriszyp described.deployStageActivate.test.jsrefuses an activation a variable would undo before anything moves, andharperConfigEnvVars-compose.test.jspins the composition.rootConfigPublication.test.jsand the activation case, and the review fixes and the release fallback were checked the same way against this branch's first commit.Gates, run locally on macOS with Node 24 at
c24777a38, withHOMEnaming a sentinel home, whose boot properties point at a throwaway install, and a fresh shortTMPDIR. The last commit's changes ran in the affected suites (347 pass).maintest:unit:mainroot-config-activation-effect,stage-then-activate,components.test.mjsmain. They are environment-specific on this Mac:/private/varrealpath comparisons, the shell's git-credential environment, git-reference fixtures that fail under a swappedHOME, the uWS UDS adapter, and process-group reclaim ordering.applicationSpawn.test.jsis excluded because it wedges locally, onmaintoo.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