Skip to content

fix(dashmate)!: stop concurrent commands reverting each other's config changes - #4248

Open
shumkov wants to merge 10 commits into
v4.2-devfrom
fix/dashmate/4242-readonly-config-clobber
Open

fix(dashmate)!: stop concurrent commands reverting each other's config changes#4248
shumkov wants to merge 10 commits into
v4.2-devfrom
fix/dashmate/4242-readonly-config-clobber

Conversation

@shumkov

@shumkov shumkov commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

Issue being fixed or feature implemented

Closes #4242.

Config's constructor delegates to setOptions(), which marks the config changed — so every config was dirty the moment it was read off disk. BaseCommand.finally() persists the whole config file whenever anything is dirty, so every successful command, including pure readers like dashmate config get and dashmate core cli, rewrote config.json from its own load-time snapshot.

That turns any overlapping pair of commands into a lost update:

  1. A reader loads the config at T0.
  2. dashmate config set …docker.image X loads, writes, prints success and exits 0 at T1.
  3. The reader finishes and writes its T0 snapshot at T2.

The setting silently reverts even though config set reported success. Reported from a real rolling masternode image-pin update, where some nodes kept the old image digest.

Two things made it hard to spot:

  • In single-process use the rewrite is byte-identical (verified against a real 58 KB, 4-config config.json), so it never shows up outside real concurrency.
  • The worst window is not a CLI command at all — scripts/helper.js reads config once at startup and writes it back on certificate renewal months later. Its guard is even commented "Persist config if it was migrated", and this bug made it fire on every startup.

What was done?

Hydration is no longer a mutation. Config's constructor resets the flag after the initial setOptions(). ConfigFile already did exactly this; Config was just missing it. This alone means read-only commands write nothing — they cannot clobber anyone.

Changing configuration reads and saves as one locked step. Reading when a command starts and saving when it exits means the state written may already be out of date. ConfigFileJsonRepository.update(mutator) takes the lock, reads the current state, applies the change and saves — the state handed to the mutator is read after the lock is held, so there is nothing stale to write:

configFileRepository.update((configFile) => {
  configFile.getConfig(name).set(path, value);
});

Used by config set, create, remove, default and group default. Two commands changing different options now both succeed.

Commands that reconfigure a node hold the lock for their run. setup, reset, group reset and ssl obtain change configuration repeatedly while doing long, partly irreversible work, so a single locked step does not fit. They declare static mutatesConfig = true; BaseCommand.init() takes the lock before reading and releases it in finally(). Reading inside the lock is what makes their state current.

Read-only commands never take the lock, so a node can still be inspected while it is being set up.

Supporting details that matter:

  • A command holding the lock reuses it for update()/write() rather than re-acquiring — otherwise the SSL task's mid-command save (obtainZeroSSLCertificateTaskFactory.js:150) would wait on itself.
  • Release is idempotent and runs from normal completion, command failure, failure before the command body, and graceful shutdown.
  • A lock lost while we believed we held it blocks the next save instead of being ignored — ignoring it is fine when a lock only narrows a window, not when it provides exclusivity.
  • Writes are atomic and preserve file mode — config.json holds masternode privateKey, Core RPC password and ZeroSSL apiKey.
  • config remove deletes the service directory only after the removal is saved, so a failed save cannot leave a config listed with nothing behind it.
  • Service templates render only for configs that actually changed. That is safe because upgrading Dashmate records the new version even when no migration applies, which marks every config changed — so a release that edits a template still reaches every node.

Merging concurrent edits was considered and rejected: in-memory holds the whole config rather than a diff, so "our copy wins" reverts every field we merely loaded — the original bug, reintroduced by the merge.

How Has This Been Tested?

193 unit tests pass; eslint clean on changed files. Behaviours were proven by breaking them, not by assertion:

Behaviour Proof it is pinned
Hydrated config is clean expected true to be false before the fix
Read-only command writes nothing asserts the write does not happen (mtime + inode) — asserting content passes on the buggy code
Concurrent edits both survive old read-early/write-late loses one: other command edit survived => false; update() → both true
Held lock is reused, not re-taken removing the guard → Lock file is already being held after 15s
Lost lock blocks the next save restoring the old no-op handler → the save succeeds and the test fails
Waiting too long reports clearly ✖ before; now an actionable dashmate-level message
Version recorded on upgrade with no migration expected '98.0.0' to equal '99.0.0'
config remove keeps service files if the save fails ✖ before

Lock timings are injectable purely so the paths that only occur after seconds of waiting can be tested in about a second. (proper-lockfile floors stale at 2s and its refresh at 1s, which sets the limit on how fast a lost lock can be noticed.)

Local E2E — 5/5 passing, packages/dashmate/test/e2e/localNetwork.spec.js: setup → start → restart → stop → reset on a real local network. CI skips this job for a dashmate-only diff, and it is the coverage that matters most here, since setup and reset are exactly the commands that now hold the lock across their whole run.

Design and code were reviewed by independent cross-model passes throughout, which caught several defects in earlier iterations — including two concurrency bugs and an error path that would have hidden the message an operator needs.

Breaking Changes

A command that changes configuration can now report that another dashmate command is modifying configuration and exit non-zero, after waiting ~15s. Previously it would have silently overwritten that other command's work. Automation that changes configuration while setup, reset or ssl obtain is running will see this; retrying once the other command finishes succeeds.

The guarantee is between cooperating dashmate versions on a local filesystem: a pre-fix process does not honour the lock, so upgrade all instances and restart the node (which restarts the helper) before relying on it.

Checklist:

  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have added or updated relevant unit/integration/functional/e2e tests
  • I have added "!" to the title and described breaking changes in the corresponding section if my code contains any
  • I have made corresponding changes to the documentation if needed

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Configuration changes are now safely coordinated when multiple Dashmate commands run concurrently.
    • Long-running setup, reset, group reset, and SSL operations protect configuration throughout execution.
    • Configuration saves are atomic, reducing the risk of partial or lost updates.
    • Newly generated miner addresses and renewed SSL settings are persisted reliably.
  • Bug Fixes

    • Prevented concurrent commands from overwriting configuration changes.
    • Fixed template regeneration and change tracking after configuration updates.
    • Improved recovery and error reporting when configuration locks are unavailable or lost.
  • Documentation

    • Updated guidance on concurrent commands, configuration locking, and lock recovery behavior.

…ng concurrent updates

`Config`'s constructor delegated to `setOptions()`, which marks the config
changed, so every config was dirty the moment it was read off disk.
`BaseCommand.finally()` persists the whole config file whenever anything is
dirty, so every successful command — including pure readers like `config get`
and `core cli` — rewrote `config.json` from its own load-time snapshot.

That turns any overlapping pair of commands into a lost update: a reader loads
at T0, `config set` writes and exits 0 at T1, and the reader writes its stale
snapshot at T2. The setting silently reverts even though `config set` reported
success. It is invisible in single-process use because re-serializing an
unchanged config is byte-identical.

- Hydrating a config no longer marks it changed. Callers that build a config
  which must reach disk already mark it explicitly.
- Rendering service templates no longer clears persistence state; the
  repository clears it, and only after the write actually succeeds.
- Writes are atomic (`write-file-atomic`, preserving file mode — `config.json`
  holds masternode keys, RPC passwords and SSL API keys).
- A write is refused when the file changed on disk since it was read, under a
  short `proper-lockfile` lock held only across compare-and-replace, never for
  the duration of a command. The refused state is parked next to `config.json`
  so material generated during the command is never lost.

The lock is cooperative and scoped to local filesystems; both limits are
documented for operators.

BREAKING CHANGE: a command that would have silently overwritten a concurrent
configuration change now fails with a non-zero exit and
`ConfigFileConflictError`. Automation that relied on the previous
last-writer-wins behaviour will now see failures where it previously saw
silent data loss.

Tests would have caught this in CI:
  hydrated config is clean                    ✖ before → ✔ after
  stale write refused, not clobbering         ✖ before → ✔ after
  concurrent first-run write refused          ✖ before → ✔ after
  deleted config file not resurrected         ✖ before → ✔ after
  saved flags cascade only after a write      ✖ before → ✔ after
  waits for a lock held by another process    ✖ before → ✔ after

The lock test spawns a real second process and fails without the lock (write
completes in 13ms instead of waiting 700ms), so it pins the lock rather than
the staleness comparison.

Closes #4242

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@shumkov
shumkov requested a review from QuantumExplorer as a code owner July 28, 2026 14:49
@github-actions github-actions Bot added this to the v4.2.0 milestone Jul 28, 2026
@coderabbitai

coderabbitai Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Dashmate configuration hydration now preserves clean state, mutations use locked read-modify-save operations with atomic persistence, and templates render from saved state. Commands, certificate and miner workflows, tests, dependencies, and concurrency documentation were updated.

Changes

Dashmate configuration locking

Layer / File(s) Summary
Configuration persistence core
packages/dashmate/src/config/..., .pnp.cjs, packages/dashmate/package.json, packages/dashmate/test/unit/config/...
Repository updates now use inter-process locking, retry and timeout handling, atomic writes, compromise detection, and post-write state tracking.
Command lock lifecycle
packages/dashmate/src/oclif/command/BaseCommand.js, packages/dashmate/src/commands/{setup,reset,...}
Commands that reconfigure nodes hold the configuration lock through execution and release it during normal or exceptional shutdown.
Mutation command integration
packages/dashmate/src/commands/..., packages/dashmate/src/helper/..., packages/dashmate/src/listr/...
Configuration mutations, renewals, certificate saves, and miner address creation use repository-backed updates and render templates from saved configuration.
Validation and concurrency guidance
packages/dashmate/test/unit/..., packages/dashmate/docs/config/index.md
Tests cover hydration, migration, persistence, concurrent locking, command behavior, and rejected updates; documentation describes the locking rules.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related PRs

Suggested reviewers: quantumexplorer

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: preventing concurrent commands from reverting config updates.
Linked Issues check ✅ Passed The changes add clean hydration and inter-process locking so config writes are durable and read-only commands stop rewriting config.json.
Out of Scope Changes check ✅ Passed The diff stays focused on config-dirty tracking, locking, atomic saves, and related tests/docs for the same concurrency fix.
Docstring Coverage ✅ Passed Docstring coverage is 93.75% which is sufficient. The required threshold is 80.00%.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/dashmate/4242-readonly-config-clobber

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai 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.

Actionable comments posted: 2

🧹 Nitpick comments (3)
packages/dashmate/test/unit/config/configFile/ConfigFileJsonRepository.spec.js (3)

9-9: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Hardcoded format version will drift.

'4.1.0' duplicates the production config format version; when it bumps, these tests seed a file that no longer matches the schema/migration expectations. Prefer importing the same constant/source the repository uses.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@packages/dashmate/test/unit/config/configFile/ConfigFileJsonRepository.spec.js`
at line 9, Replace the hardcoded CURRENT_FORMAT_VERSION in
ConfigFileJsonRepository.spec.js with the production config format version
constant or source used by ConfigFileJsonRepository, so tests always seed files
using the current schema version.

297-300: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Hot busy-wait spins a core for up to 10s on failure.

fs.existsSync in a tight loop pegs a CPU in CI when the child fails to start. Reuse an Atomics.wait-based sleep between polls.

♻️ Suggested tweak
       const deadline = Date.now() + 10000;
       while (!fs.existsSync(lockPath) && Date.now() < deadline) {
-        // busy-wait
+        Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
       }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@packages/dashmate/test/unit/config/configFile/ConfigFileJsonRepository.spec.js`
around lines 297 - 300, Replace the tight polling loop around lockPath in the
ConfigFileJsonRepository test with an Atomics.wait-based sleep between
fs.existsSync checks, preserving the existing 10-second deadline and success
condition. Ensure the polling still exits promptly when the lock appears while
avoiding continuous CPU usage.

273-319: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Child process may leak if an assertion throws.

Any failing expect between Lines 302-316 aborts the test before the exit listener path completes, leaving the spawned child (and the lock dir) around until its own timer fires. A child.kill() in an afterEach/try-finally makes the failure mode deterministic.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@packages/dashmate/test/unit/config/configFile/ConfigFileJsonRepository.spec.js`
around lines 273 - 319, Ensure the spawned child in the “should wait for a lock
held by another process before writing” test is always cleaned up when an
assertion or repository operation fails. Wrap the synchronous test flow in
try/finally or add equivalent teardown that kills the child and removes any
remaining lock directory, while preserving the existing exit callback and
success assertions.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/dashmate/docs/config/index.md`:
- Around line 192-197: Add the `text` language identifier to the fenced code
block containing the configuration overwrite warning in the documentation, while
preserving the warning text unchanged.

In `@packages/dashmate/src/config/configFile/ConfigFileJsonRepository.js`:
- Around line 174-198: Update the lock acquisition and release flow around
`#acquireLock` so proper-lockfile uses a non-throwing onCompromised handler and
release failures are caught in finally. Preserve the original operation or
ConfigFileConflictError when release() reports ERELEASED, and prevent
compromised-lock refresh errors from becoming uncaught asynchronous exceptions.

---

Nitpick comments:
In
`@packages/dashmate/test/unit/config/configFile/ConfigFileJsonRepository.spec.js`:
- Line 9: Replace the hardcoded CURRENT_FORMAT_VERSION in
ConfigFileJsonRepository.spec.js with the production config format version
constant or source used by ConfigFileJsonRepository, so tests always seed files
using the current schema version.
- Around line 297-300: Replace the tight polling loop around lockPath in the
ConfigFileJsonRepository test with an Atomics.wait-based sleep between
fs.existsSync checks, preserving the existing 10-second deadline and success
condition. Ensure the polling still exits promptly when the lock appears while
avoiding continuous CPU usage.
- Around line 273-319: Ensure the spawned child in the “should wait for a lock
held by another process before writing” test is always cleaned up when an
assertion or repository operation fails. Wrap the synchronous test flow in
try/finally or add equivalent teardown that kills the child and removes any
remaining lock directory, while preserving the existing exit callback and
success assertions.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: d46df355-b215-4071-93e5-830b41f4f559

📥 Commits

Reviewing files that changed from the base of the PR and between ed4116b and b7a53e3.

⛔ Files ignored due to path filters (3)
  • .yarn/cache/proper-lockfile-npm-4.1.2-a140a3c928-000a4875f5.zip is excluded by !**/.yarn/**, !**/*.zip
  • .yarn/cache/write-file-atomic-npm-5.0.1-52283db6ee-648efddba5.zip is excluded by !**/.yarn/**, !**/*.zip
  • yarn.lock is excluded by !**/yarn.lock, !**/*.lock
📒 Files selected for processing (11)
  • .pnp.cjs
  • packages/dashmate/docs/config/index.md
  • packages/dashmate/package.json
  • packages/dashmate/scripts/helper.js
  • packages/dashmate/src/config/Config.js
  • packages/dashmate/src/config/configFile/ConfigFileJsonRepository.js
  • packages/dashmate/src/config/errors/ConfigFileConflictError.js
  • packages/dashmate/src/oclif/command/BaseCommand.js
  • packages/dashmate/src/templates/writeConfigTemplatesFactory.js
  • packages/dashmate/test/unit/config/Config.spec.js
  • packages/dashmate/test/unit/config/configFile/ConfigFileJsonRepository.spec.js
💤 Files with no reviewable changes (1)
  • packages/dashmate/src/templates/writeConfigTemplatesFactory.js

Comment thread packages/dashmate/docs/config/index.md Outdated
Comment thread packages/dashmate/src/config/configFile/ConfigFileJsonRepository.js Outdated
shumkov and others added 2 commits July 28, 2026 23:14
`proper-lockfile`'s `release()` reports ERELEASED/ENOTACQUIRED when the lock
was compromised or already gone. Calling it bare in `finally` let that replace
the outcome the caller needs — an operator hitting a concurrent write would
have been told "Lock is already released" instead of being given the conflict
and the path their configuration was parked at. Release failures are now
contained; a lock that cannot be released goes stale and is reclaimed by the
next writer.

The library's default `onCompromised` rethrows, and it runs from a refresh
timer rather than the caller's stack, so a lock compromised mid-write would
have taken the whole process down. Replaced with a handler that does not
throw: the critical section lasts milliseconds, and the byte comparison
against the baseline — not the lock — is what actually prevents a lost update.

Also labels a fenced block in the config docs (MD040).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ing its child

The poll waiting for the other process to take the lock was a tight
`fs.existsSync` loop, which pegs a CI core for the full ten-second timeout
whenever the child fails to start. It now sleeps between polls.

The spawned lock holder was only cleaned up on the success path, so a failing
assertion left it running — still owning the lock directory — after the test
that created it had gone. It is tracked and killed in `afterEach` instead, so
the failure mode is deterministic.

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

shumkov commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator Author

Dispositions for the three nitpicks from the CodeRabbit review, for the record.

Fixed in 235801568c:

  • Hot busy-wait spins a core for up to 10s on failure — valid, and the worse failure mode is on a CI runner where the child never starts. The poll waiting for the other process to take the lock now sleeps between attempts instead of spinning.
  • Child process may leak if an assertion throws — valid. The spawned lock holder was only cleaned up on the success path, so a failing assertion left it running and still owning the lock directory after its test was gone. It is now tracked and killed in afterEach, making the failure mode deterministic.

Declining, with reasoning:

  • Hardcoded '4.1.0' format version will drift — the concern is that the seeded file stops matching schema/migration expectations when the real version bumps. It cannot: these tests inject an identity migration, so migratedConfigFileData.configFormatVersion === originConfigVersion holds for any value, and the schema constrains the field's type rather than its value. The literal is deliberately arbitrary test data, not a mirror of the production constant — importing the real one would couple the fixture to a value the test does not depend on, and would make the migration-path test (which uses '9.9.9') inconsistent with its sibling. Happy to revisit if a reviewer disagrees.

Every command re-rendered and rewrote all service templates — 40 files for a
four-config home directory, on `dashmate config get` as much as on `setup`.
That was never intentional: hydration marked every config changed, so the
existing "changed configs only" filter matched all of them. Fixing hydration
left the filter correct but the rendering unconditional, which kept half of
the original side effect alive.

Rendering only what changed is safe because upgrading Dashmate records the new
version in the config file even when no migration applies, and
`ConfigFileJsonRepository.read()` marks every config changed when that recorded
version moved. So a release that edits a template still reaches every node on
its next command, without paying for it on every unrelated command.

That property was load-bearing and untested, so it is pinned now: removing the
version stamp from `migrateConfigFile` fails the new test with
`expected '98.0.0' to equal '99.0.0'`.

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

thepastaclaw commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

🕓 Ready for review — 1 ahead in queue (commit aae3308)
Queue position: 2/2 · 2 reviews active
ETA: start ~16:01 UTC · complete ~16:27 UTC (median 26m across 30 recent reviews; 2 slots)
Queued 18m ago · Last checked: 2026-07-30 15:40 UTC

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Preliminary review — Codex only

The compare-and-replace write path prevents stale writers from overwriting config.json, but the new dirty-state lifecycle is not safely integrated with existing SSL and template workflows. Intermediate SSL saves can discard pending render work, completed JSON writes can permanently consume the only template-upgrade signal, and the long-lived helper cannot recover after another process updates the file. These are three blocking regressions.

Validated blockers were found in the Codex precheck. Sonnet is deferred until a fresh Codex revalidation clears the blocker gate.

Review provenance

  • Codex reviewers: gpt-5.6-sol — general (completed)
  • Verifier: gpt-5.6-sol — verifier
  • Sonnet: not run (deferred by blocker gate)

🔴 3 blocking

1 additional finding(s) omitted (not in diff).

🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/dashmate/src/config/configFile/ConfigFileJsonRepository.js`:
- [BLOCKING] packages/dashmate/src/config/configFile/ConfigFileJsonRepository.js:194-195: Intermediate SSL saves discard pending template renders
  Clearing every nested `Config` flag here breaks callers that save during a command and rely on `BaseCommand.finally()` to render afterward. The ZeroSSL pipeline updates `enabled`, `provider`, and the certificate ID and writes the `ConfigFile` at `obtainZeroSSLCertificateTaskFactory.js:145-150`; its remaining tasks only write certificate files, so nothing marks the config changed again. `BaseCommand.finally()` consequently sees a clean file and skips rendering, which can leave `config.json` switched from self-signed to ZeroSSL while Envoy still has the old listener configuration. The Let's Encrypt save task re-dirties the selected config, but its intermediate write still clears migration-triggered flags on every other config, suppressing their required first-command-after-upgrade renders. Preserve pending-template state across intermediate persistence, or explicitly render the captured pending configs after these writes.

In `packages/dashmate/src/oclif/command/BaseCommand.js`:
- [BLOCKING] packages/dashmate/src/oclif/command/BaseCommand.js:119-130: A template failure permanently consumes the upgrade marker
  `BaseCommand.finally()` commits the new config format version and clears all dirty flags before rendering any service template. If rendering or writing a template throws, or the process terminates after the config write, `config.json` remains stamped with the current version while one or more generated files remain stale. The next process hydrates every config as clean, and migration no longer marks them because the recorded version already matches, so the failed and unattempted templates are not retried automatically. This defeats the upgrade invariant on which render-only-changed behavior relies. Keep a durable pending-render indication until all affected templates have been written successfully, or provide an equivalent recovery mechanism.

In `packages/dashmate/scripts/helper.js`:
- [BLOCKING] packages/dashmate/scripts/helper.js:93: The helper cannot recover after another process updates config.json
  The helper reads `config.json` only once and retains that repository, `ConfigFile`, and selected `Config` for its entire lifetime. Under the new compare-and-replace contract, any later Dashmate command that changes the shared file invalidates the helper's baseline, including changes to an unrelated config. Certificate renewal then mutates the stale objects and writes through the stale repository, producing `ConfigFileConflictError`. Both renewal schedulers catch failures and schedule subsequent work with the same objects without re-reading, so every future renewal write remains unable to commit. Because the error is swallowed, the `unless-stopped` helper container stays alive rather than restarting and reloading, allowing gateway certificate renewal to remain broken until an external restart. Reload and reselect the current config before renewal mutations, or explicitly abandon and reload the helper state after a conflict.

Comment on lines +194 to +195
configFile.markAsSaved();
configFile.getAllConfigs().forEach((config) => config.markAsSaved());

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🔴 Blocking: Intermediate SSL saves discard pending template renders

Clearing every nested Config flag here breaks callers that save during a command and rely on BaseCommand.finally() to render afterward. The ZeroSSL pipeline updates enabled, provider, and the certificate ID and writes the ConfigFile at obtainZeroSSLCertificateTaskFactory.js:145-150; its remaining tasks only write certificate files, so nothing marks the config changed again. BaseCommand.finally() consequently sees a clean file and skips rendering, which can leave config.json switched from self-signed to ZeroSSL while Envoy still has the old listener configuration. The Let's Encrypt save task re-dirties the selected config, but its intermediate write still clears migration-triggered flags on every other config, suppressing their required first-command-after-upgrade renders. Preserve pending-template state across intermediate persistence, or explicitly render the captured pending configs after these writes.

source: ['codex']

Comment on lines 119 to +130
configFileRepository.write(configFile);

/**
* @var {writeConfigTemplates} writeConfigTemplates
*/
const writeConfigTemplates = this.container.resolve('writeConfigTemplates');

configFile.getAllConfigs()
.filter((config) => config.isChanged())
.forEach(writeConfigTemplates);
// Re-rendering only what changed is safe because upgrading Dashmate
// stamps the new version into the config file even when no migration
// applies, which marks every config changed - so a release that edits
// a template still reaches every node on its next command.
changedConfigs.forEach(writeConfigTemplates);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🔴 Blocking: A template failure permanently consumes the upgrade marker

BaseCommand.finally() commits the new config format version and clears all dirty flags before rendering any service template. If rendering or writing a template throws, or the process terminates after the config write, config.json remains stamped with the current version while one or more generated files remain stale. The next process hydrates every config as clean, and migration no longer marks them because the recorded version already matches, so the failed and unattempted templates are not retried automatically. This defeats the upgrade invariant on which render-only-changed behavior relies. Keep a durable pending-render indication until all affected templates have been written successfully, or provide an equivalent recovery mechanism.

source: ['codex']

Reading the config file when a command starts and saving it when the command
exits means the state written is a snapshot that may already be out of date, so
an overlapping command's change is reverted. Detecting that afterwards — refusing
the write, parking the rejected state in a file and asking the operator to
reconcile — treated the symptom and put the burden on them.

`ConfigFileJsonRepository.update(mutator)` instead takes the lock, reads the
current state, applies the change and saves, all in one step. The state handed to
the mutator is read after the lock is held, so there is nothing stale to write and
nothing to detect: two commands changing different options now both succeed.

`dashmate config set` uses it, against the config name resolved for that command
rather than re-resolving the default, which another process may have changed.

Removed, because none of it has anything left to do:

- `ConfigFileConflictError`
- the `config.json.rejected-*` snapshots and their naming
- the observed-state baseline and the compare-before-write
- the operator recovery procedure in the config docs

Demonstrated on a real config file — read-early/write-late loses the other
command's edit, `update()` keeps both:

  OLD read-early/write-late: other command edit survived => false
  NEW update():             other command edit survived => true
                            this command edit survived  => true

`setup`, `reset` and `ssl obtain` still load at start and save at exit, so
changing config with `config set` while one of them runs can still lose the
change. Documented rather than solved: they are exclusive operations, and the
mechanisms that would cover them cost more than the case is worth.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@shumkov
shumkov force-pushed the fix/dashmate/4242-readonly-config-clobber branch from 6702178 to 7abb4c3 Compare July 30, 2026 12:45
…command

`config set` already read, changed and saved as one step; `create`, `remove`,
`default` and `group default` still mutated the config file loaded at startup and
relied on it being saved on exit, so they could revert a change another command
saved while they ran.

`config remove` also deleted the service directory before that save, so a failed
save left a config listed in config.json with nothing on disk behind it. The
directory is now deleted only once the removal is saved, and removing a config
another command already removed fails before anything is written.

`config create` renders the new config's service files itself. It used to get
that from the generic save-on-exit, which no longer sees a change because the
config file handed to the command is deliberately not the one that was modified.

Verified end to end for each command: the change reaches disk and the config file
loaded at startup is left untouched, which is what stops the generic save from
writing a pre-command snapshot afterwards.

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

@coderabbitai coderabbitai 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.

🧹 Nitpick comments (2)
packages/dashmate/src/commands/config/create.js (1)

21-45: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Document the new injected parameters.

configFileRepository and writeConfigTemplates are resolved by name from the DI container; leaving them out of the JSDoc makes the injection contract easy to break on rename. Same applies to default.js, remove.js, set.js, and group/default.js.

♻️ Proposed doc update
   /**
    * `@param` {Object} args
    * `@param` {Object} flags
    * `@param` {ConfigFile} configFile
+   * `@param` {ConfigFileJsonRepository} configFileRepository
+   * `@param` {writeConfigTemplates} writeConfigTemplates
    * `@return` {Promise<void>}
    */
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/dashmate/src/commands/config/create.js` around lines 21 - 45, Update
the JSDoc for runWithDependencies in create.js to document the injected
configFileRepository and writeConfigTemplates parameters, including their
expected types. Apply the same dependency-parameter documentation to the
corresponding runWithDependencies methods in default.js, remove.js, set.js, and
group/default.js, preserving the existing injection names and behavior.
packages/dashmate/test/unit/commands/config/mutatingCommands.spec.js (1)

114-126: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Assert the command actually rejects.

.catch(() => {}) swallows the outcome, so the test would still pass if config remove silently succeeded on a missing config and simply skipped the delete. Pin the failure explicitly.

♻️ Proposed test tightening
-      await new ConfigRemoveCommand().runWithDependencies(
-        { config: 'does-not-exist' },
-        flags,
-        loadedConfigFile,
-        { has: () => false },
-        homeDir,
-        configFileRepository,
-      ).catch(() => {});
+      let error;
+      try {
+        await new ConfigRemoveCommand().runWithDependencies(
+          { config: 'does-not-exist' },
+          flags,
+          loadedConfigFile,
+          { has: () => false },
+          homeDir,
+          configFileRepository,
+        );
+      } catch (e) {
+        error = e;
+      }
+
+      expect(error).to.exist();
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/dashmate/test/unit/commands/config/mutatingCommands.spec.js` around
lines 114 - 126, Update the test around ConfigRemoveCommand.runWithDependencies
to explicitly assert that the command rejects when removing the missing
“does-not-exist” configuration. Replace the empty catch that swallows the
outcome with an assertion on the rejected promise, while preserving the existing
checks that the service directory and configuration remain unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@packages/dashmate/src/commands/config/create.js`:
- Around line 21-45: Update the JSDoc for runWithDependencies in create.js to
document the injected configFileRepository and writeConfigTemplates parameters,
including their expected types. Apply the same dependency-parameter
documentation to the corresponding runWithDependencies methods in default.js,
remove.js, set.js, and group/default.js, preserving the existing injection names
and behavior.

In `@packages/dashmate/test/unit/commands/config/mutatingCommands.spec.js`:
- Around line 114-126: Update the test around
ConfigRemoveCommand.runWithDependencies to explicitly assert that the command
rejects when removing the missing “does-not-exist” configuration. Replace the
empty catch that swallows the outcome with an assertion on the rejected promise,
while preserving the existing checks that the service directory and
configuration remain unchanged.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 18ab5426-a856-46b8-bfa7-fbcbbc673ca0

📥 Commits

Reviewing files that changed from the base of the PR and between e23dbdf and 95611b9.

📒 Files selected for processing (10)
  • packages/dashmate/docs/config/index.md
  • packages/dashmate/src/commands/config/create.js
  • packages/dashmate/src/commands/config/default.js
  • packages/dashmate/src/commands/config/remove.js
  • packages/dashmate/src/commands/config/set.js
  • packages/dashmate/src/commands/group/default.js
  • packages/dashmate/src/config/configFile/ConfigFileJsonRepository.js
  • packages/dashmate/test/unit/commands/config/mutatingCommands.spec.js
  • packages/dashmate/test/unit/commands/config/set.spec.js
  • packages/dashmate/test/unit/config/configFile/ConfigFileJsonRepository.spec.js

shumkov and others added 2 commits July 30, 2026 21:11
`setup`, `reset`, `group reset` and `ssl obtain` change configuration repeatedly
while doing long, partly irreversible work. They still loaded config when they
started and saved it when they finished, so a `config set` running in between was
reverted — the one case the locked read-change-save step could not cover, since
their changes are not a single self-contained edit.

They now take the lock before reading, via `static mutatesConfig = true`, and hold
it for the run. Reading inside the lock is what makes their state current: there
is no window left for another writer. Read-only commands take no lock, so a node
can still be inspected while it is being set up.

Three things this needs to be safe:

- The lock is reused, not re-taken, by `update()` and `write()` during the
  command. Taking it again would leave the command waiting on itself until the
  acquire timeout — verified: removing the guard fails the new test with "Lock
  file is already being held" after 15s.
- Release covers every way out: normal finish, command failure, a failure before
  the command body runs, and graceful shutdown. It is idempotent so those paths
  need not coordinate.
- A lock lost while we believed we held it now blocks the next save instead of
  being ignored. Ignoring it was right when the lock only narrowed a window;
  it is not right when the lock is what provides exclusivity.

The stale threshold moves to 60s so a long command cannot have its live lock
stolen during a synchronous stretch, while the acquire wait drops to 15s so a
waiting command reports quickly rather than stalling.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…wait

Losing a held lock and giving up waiting for someone else's were both reachable
only after seconds of real time, so neither was tested — the compromise handling
in particular rested on reading the code.

Lock timings are now injectable, which lets both run in about a second. They are
real regression tests: with the previous no-op compromise handler, saving after
losing the lock succeeds and the test fails.

Waiting too long now reports in dashmate's terms — which command is holding
configuration and what to do — instead of surfacing the locking library's "Lock
file is already being held".

Worth recording, since it is not obvious and it sets the floor on how fast a lost
lock can possibly be noticed: proper-lockfile clamps `stale` to at least 2s and
the refresh that detects loss to at least 1s.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@shumkov shumkov changed the title fix(dashmate)!: stop read-only commands rewriting config and clobbering concurrent updates fix(dashmate)!: stop concurrent commands reverting each other's config changes Jul 30, 2026
Converting the config commands left two mechanisms writing configuration, and
the older one could still overwrite the newer. A cross-model review found five
ways that showed up; all of them come from the generic save-on-exit persisting a
copy loaded before the command ran.

- `BaseCommand.finally()` now saves only for commands that hold the lock for
  their whole run. They read inside the lock, so their copy is current. Every
  other command changes configuration through `update()`, and saving its startup
  copy afterwards would revert whatever that produced — most visibly on a config
  file needing migration, where `config set` would have reverted its own change.
- Migrating is saved where it happens, at startup, instead of being carried to
  the end of the command as a pending change.
- `update()` creates the config file when there is none, so `config create` and
  friends work on first run. They previously threw `ConfigFileNotFoundError`
  after `BaseCommand` had built defaults that only the old save-on-exit knew
  about.
- Rendering service files and removing a config's directory happen inside the
  lock, so two commands cannot save in one order and take effect in the other,
  and a removal cannot delete files a concurrent re-creation just wrote.
- `ssl obtain` renders its own templates. Saving marks the configs clean, so
  nothing later knew the new certificate had to reach the gateway config.
- Local group start and restart save the generated miner address as its own
  locked step rather than leaving it for exit, which would have written a copy
  from before a start that runs for tens of minutes.
- Helper certificate renewal re-applies its result onto current state. It holds
  its copy for the life of the process — months — so saving that copy reverted
  everything changed on the node since it started.

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

@coderabbitai coderabbitai 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.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
packages/dashmate/src/config/configFile/ConfigFileJsonRepository.js (1)

317-350: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

#compromised is never reset on the standalone lock path — a single compromise permanently disables saves.

#compromised is only cleared in acquire() (line 210). #locked()'s standalone branch (used by every plain update()/write() call, e.g. from scheduleRenewLetsEncryptCertificateFactory/scheduleRenewZeroSslCertificateFactory) reaches #acquireLock() directly and never resets the flag after a fresh, successful acquisition. Once onCompromised fires once (line 332-334) on any instance — including a long-lived singleton used by the helper daemon — every future save via that instance throws "Lost the lock" forever, even though each call re-acquires the lock legitimately, until the process restarts.

🔒 Proposed fix: reset compromise state on every successful acquisition
   `#acquireLock`() {
     const deadline = Date.now() + this.lockAcquireTimeoutMs;
 
     for (;;) {
       try {
-        return lockfile.lockSync(this.configFilePath, {
+        const release = lockfile.lockSync(this.configFilePath, {
           lockfilePath: this.lockFilePath,
           realpath: false,
           stale: this.lockStaleMs,
           onCompromised: () => {
             this.#compromised = true;
           },
         });
+
+        // A fresh, successful acquisition re-establishes exclusivity, so a
+        // compromise recorded during an earlier session must not keep
+        // failing saves made under this new lock.
+        this.#compromised = false;
+
+        return release;
       } catch (e) {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/dashmate/src/config/configFile/ConfigFileJsonRepository.js` around
lines 317 - 350, Reset `#compromised` after every successful lock acquisition in
`#acquireLock`(), including the standalone path used by `#locked`() for plain
update()/write() calls. Set it only after lockfile.lockSync succeeds, while
preserving the existing compromised callback and error-handling behavior.
packages/dashmate/src/oclif/command/BaseCommand.js (1)

145-183: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Write-before-render ordering loses the upgrade/migration retry marker on template failure (two sites, one root cause). In both places, configFileRepository.write(configFile) commits and clears dirty/version flags before templates are rendered from the (now stale) captured changedConfigs; if rendering throws partway through, the config file is durably marked clean/current while some generated service files remain stale, with no future migration-triggered retry. This matches a previously-raised, apparently still-unaddressed review finding on this pattern.

  • packages/dashmate/src/oclif/command/BaseCommand.js#L145-L183: in saveConfigAndStopContainers, render changedConfigs via writeConfigTemplates (or otherwise confirm success) before calling configFileRepository.write(configFile), or retain a separate pending-render marker that survives a partial rendering failure.
  • packages/dashmate/src/oclif/command/BaseCommand.js#L73-L85: apply the same reordering/marker fix to the migration-triggered write in init().
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/dashmate/src/oclif/command/BaseCommand.js` around lines 145 - 183,
In packages/dashmate/src/oclif/command/BaseCommand.js lines 145-183, update
saveConfigAndStopContainers to render changedConfigs with writeConfigTemplates
successfully before configFileRepository.write(configFile), or preserve a
pending-render marker across partial failures. Apply the same ordering or marker
fix to the migration-triggered write in init at lines 73-85; both sites must
prevent the configuration from being marked clean/current until all template
rendering completes.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/dashmate/src/helper/scheduleRenewLetsEncryptCertificateFactory.js`:
- Around line 78-91: The certificate renewal schedulers must update only
renewal-produced fields on freshly loaded configuration, rather than replacing
stale SSL snapshots or persisting stale task inputs. In
packages/dashmate/src/helper/scheduleRenewLetsEncryptCertificateFactory.js:78-91
and
packages/dashmate/src/helper/scheduleRenewZeroSslCertificateFactory.js:82-95,
change the fresh-state callbacks to patch individual renewed fields; in
packages/dashmate/src/listr/tasks/ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.js:257-261
and
packages/dashmate/src/listr/tasks/ssl/zerossl/obtainZeroSSLCertificateTaskFactory.js:151-155,
skip the direct write(configFile) when invoked by the background schedulers so
the scheduler updates own persistence.

In `@packages/dashmate/src/listr/tasks/startGroupNodesTaskFactory.js`:
- Around line 96-101: Remove the subsequent minerConfig.set(...) assignment in
the group-node startup flow after configFileRepository.update. Use the existing
minerAddress value for the downstream logic, leaving minerConfig as the original
snapshot so command-exit persistence cannot overwrite concurrent configuration
changes.

---

Outside diff comments:
In `@packages/dashmate/src/config/configFile/ConfigFileJsonRepository.js`:
- Around line 317-350: Reset `#compromised` after every successful lock
acquisition in `#acquireLock`(), including the standalone path used by `#locked`()
for plain update()/write() calls. Set it only after lockfile.lockSync succeeds,
while preserving the existing compromised callback and error-handling behavior.

In `@packages/dashmate/src/oclif/command/BaseCommand.js`:
- Around line 145-183: In packages/dashmate/src/oclif/command/BaseCommand.js
lines 145-183, update saveConfigAndStopContainers to render changedConfigs with
writeConfigTemplates successfully before configFileRepository.write(configFile),
or preserve a pending-render marker across partial failures. Apply the same
ordering or marker fix to the migration-triggered write in init at lines 73-85;
both sites must prevent the configuration from being marked clean/current until
all template rendering completes.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 8dda59f3-48b6-4f14-a47b-e495ca1ed7c1

📥 Commits

Reviewing files that changed from the base of the PR and between 95611b9 and aae3308.

📒 Files selected for processing (20)
  • packages/dashmate/docs/config/index.md
  • packages/dashmate/scripts/helper.js
  • packages/dashmate/src/commands/config/create.js
  • packages/dashmate/src/commands/config/remove.js
  • packages/dashmate/src/commands/config/set.js
  • packages/dashmate/src/commands/group/reset.js
  • packages/dashmate/src/commands/reset.js
  • packages/dashmate/src/commands/setup.js
  • packages/dashmate/src/commands/ssl/obtain.js
  • packages/dashmate/src/config/configFile/ConfigFileJsonRepository.js
  • packages/dashmate/src/createDIContainer.js
  • packages/dashmate/src/helper/scheduleRenewLetsEncryptCertificateFactory.js
  • packages/dashmate/src/helper/scheduleRenewZeroSslCertificateFactory.js
  • packages/dashmate/src/listr/tasks/ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.js
  • packages/dashmate/src/listr/tasks/ssl/zerossl/obtainZeroSSLCertificateTaskFactory.js
  • packages/dashmate/src/listr/tasks/startGroupNodesTaskFactory.js
  • packages/dashmate/src/oclif/command/BaseCommand.js
  • packages/dashmate/test/unit/commands/config/mutatingCommands.spec.js
  • packages/dashmate/test/unit/commands/config/set.spec.js
  • packages/dashmate/test/unit/config/configFile/ConfigFileJsonRepository.spec.js
🚧 Files skipped from review as they are similar to previous changes (2)
  • packages/dashmate/scripts/helper.js
  • packages/dashmate/docs/config/index.md

Comment on lines +78 to +91
// This process has held its copy of the config file since it started,
// possibly for months, so saving that copy would revert everything
// changed on the node since. Re-apply just what the renewal produced
// onto the current state instead.
const renewedOptions = config.get('platform.gateway.ssl');

configFileRepository.update((freshConfigFile) => {
freshConfigFile.getConfig(config.getName())
.set('platform.gateway.ssl', renewedOptions);
}, {
onSaved: (savedConfigFile) => writeConfigTemplates(
savedConfigFile.getConfig(config.getName()),
),
});

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.

🗄️ Data Integrity & Integration | 🔴 Critical | 🏗️ Heavy lift

Renewal still writes stale configuration snapshots. The schedulers retain config for potentially months, while the certificate tasks directly save the injected configFile; either write can discard unrelated concurrent changes. The later full platform.gateway.ssl replacement can also overwrite newer SSL settings.

  • packages/dashmate/src/helper/scheduleRenewLetsEncryptCertificateFactory.js#L78-L91: patch only renewal-produced fields onto freshly loaded state; do not replace the stale SSL subtree.
  • packages/dashmate/src/helper/scheduleRenewZeroSslCertificateFactory.js#L82-L95: apply the same field-level fresh-state update.
  • packages/dashmate/src/listr/tasks/ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.js#L257-L261: avoid write(configFile) when invoked by the background scheduler; let its fresh-state update own persistence.
  • packages/dashmate/src/listr/tasks/ssl/zerossl/obtainZeroSSLCertificateTaskFactory.js#L151-L155: avoid the equivalent direct stale-file write.
📍 Affects 4 files
  • packages/dashmate/src/helper/scheduleRenewLetsEncryptCertificateFactory.js#L78-L91 (this comment)
  • packages/dashmate/src/helper/scheduleRenewZeroSslCertificateFactory.js#L82-L95
  • packages/dashmate/src/listr/tasks/ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.js#L257-L261
  • packages/dashmate/src/listr/tasks/ssl/zerossl/obtainZeroSSLCertificateTaskFactory.js#L151-L155
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/dashmate/src/helper/scheduleRenewLetsEncryptCertificateFactory.js`
around lines 78 - 91, The certificate renewal schedulers must update only
renewal-produced fields on freshly loaded configuration, rather than replacing
stale SSL snapshots or persisting stale task inputs. In
packages/dashmate/src/helper/scheduleRenewLetsEncryptCertificateFactory.js:78-91
and
packages/dashmate/src/helper/scheduleRenewZeroSslCertificateFactory.js:82-95,
change the fresh-state callbacks to patch individual renewed fields; in
packages/dashmate/src/listr/tasks/ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.js:257-261
and
packages/dashmate/src/listr/tasks/ssl/zerossl/obtainZeroSSLCertificateTaskFactory.js:151-155,
skip the direct write(configFile) when invoked by the background schedulers so
the scheduler updates own persistence.

Comment on lines +96 to +101
// Saved as its own locked step rather than carried to the end of the
// command: starting a group runs for many minutes, and the address is
// needed by the miner right now.
configFileRepository.update((configFile) => {
configFile.getConfig(minerConfig.getName()).set('core.miner.address', minerAddress);
});

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.

🗄️ Data Integrity & Integration | 🔴 Critical | ⚡ Quick win

Do not re-dirty the stale minerConfig snapshot.

The following minerConfig.set(...) at Line 103 defeats this locked update: the command-exit dirty save can overwrite concurrent changes with its startup snapshot. Remove that assignment; minerAddress already supplies the value needed below.

Suggested fix
             configFileRepository.update((configFile) => {
               configFile.getConfig(minerConfig.getName()).set('core.miner.address', minerAddress);
             });
-
-            minerConfig.set('core.miner.address', minerAddress);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Saved as its own locked step rather than carried to the end of the
// command: starting a group runs for many minutes, and the address is
// needed by the miner right now.
configFileRepository.update((configFile) => {
configFile.getConfig(minerConfig.getName()).set('core.miner.address', minerAddress);
});
// Saved as its own locked step rather than carried to the end of the
// command: starting a group runs for many minutes, and the address is
// needed by the miner right now.
configFileRepository.update((configFile) => {
configFile.getConfig(minerConfig.getName()).set('core.miner.address', minerAddress);
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/dashmate/src/listr/tasks/startGroupNodesTaskFactory.js` around lines
96 - 101, Remove the subsequent minerConfig.set(...) assignment in the
group-node startup flow after configFileRepository.update. Use the existing
minerAddress value for the downstream logic, leaving minerConfig as the original
snapshot so command-exit persistence cannot overwrite concurrent configuration
changes.

The previous commit extended the locked read-change-save to SSL issuance, the
helper's certificate renewal and local group start. Review found each of those
introduced a new defect: templates rendered before a certificate was actually
usable, a renewal that reverted a provider change made since the helper started,
and a miner address chosen from stale state then written over fresh state.

None of them is needed to fix the reported bug, and they touch certificate
issuance, where being wrong costs an operator a working gateway. They are
reverted to their previous behaviour and left for separate changes with their
own tests.

Migration is now read and saved under one lock rather than read unlocked and
saved after. Splitting them left the same window this change exists to remove: a
change saved in between would have been reverted by the migrated copy. Service
files are re-rendered for whatever the migration changed, which the config file's
own save no longer implies.

Also fixes a test that passed for the wrong reason: `config default` set the
config that was already the default, so it would have passed with the save
deleted. It now points at a different config.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.

Dashmate read-only commands rewrite config and can clobber concurrent updates

2 participants