DSH plugin boot race: non-atomic cordis.yml rewrite

TroubleshootingPublished 2026-09-12Author: DeepSeek Plugin Market
DeepSeek HarnessDSH plugincordis.ymlconcurrent launchatomic write
Several headless instances launch at once and one dies with failed to validate config file: every boot rewrites cordis.yml in place.

If you scheduled dsh --profile headless and allow several instances to run concurrently, one of them may intermittently exit non-zero at boot with failed to validate config file .../cordis.yml — while the file looks perfectly fine when you inspect it afterwards. The cause is that DeepSeek Harness rewrites the profile's cordis.yml in place on every boot (O_TRUNC plus write, same inode, no temp file, no rename, no lock), so the file is momentarily zero-length on each boot; a second process opening it during that instant reads an empty document, fails the top-level-array check, and exits (#441).

Symptom: DeepSeek Harness concurrent launches fail intermittently, intact file afterwards

This bug is hard to triage mainly because the scene of the crime is gone by the time you arrive — the evidence lasts an instant, and the file is fine afterwards. Concretely:

  1. The trigger is routine: on one machine with a shared configuration directory, launch two or more headless runs at the same moment:
dsh --profile headless "task A" &
dsh --profile headless "task B" &

One of them intermittently exits non-zero at boot with:

Error: dsh: plugin tree failed to load: failed to apply loader entry include (cordis:include):
  failed to validate config file <DSH_HOME>/profiles/headless/cordis.yml

(#441) 2. A close relative shows up too: when the overlay is read during an unrelated editor save (that read is not atomic either), you get a different error — again pointing at a file that is entirely legal by the time anyone looks:

Error: dsh: overlay <DSH_HOME>/profiles/headless/cordis.patch.yml
  must be a top-level YAML array of loader patch entries

(#441) 3. The rate matches a race: roughly 2 failures across about 14 concurrent launches — intermittent, not fixed; and the profile files are intact afterwards, which is the most disorienting part (#441). 4. Evidence: the rewrite is an in-place overwrite, not a replacement. Across one boot, cordis.yml keeps both its inode and size while mtime advances, so it is not written to a temp file and renamed:

before boot: mtime=1786642203  size=223  inode=11562898
after  boot: mtime=1786642867  size=223  inode=11562898

(#441) 5. Evidence: the rewrite truncates first. Watching the file with inotify during one boot shows two MODIFY events between one OPEN and its CLOSE_WRITE — the signature of O_TRUNC-then-write via writeFileSync; the final OPEN 10 ms later is another process reading the same file (#441). 6. The window is tiny, which is why only concurrent readers hit it: polling stat at 1 ms intervals for 25 seconds (23,693 samples) never observed a size other than 223 bytes. In other words the window is sub-millisecond, reachable only by a reader that happens to open inside it — which is precisely what concurrent boots do (#441). 7. The impact is reliability, not safety: the process dies during loader init, before any tool runs, so nothing unapproved executes (fail-fast rather than fail-open). The cost is that a scheduled run is silently skipped unless the scheduler inspects exit codes — and the error text points at a config file that is valid whenever a human checks it (#441).

Mechanism: DeepSeek Harness prepareProfile unconditionally rewrites cordis.yml on every boot

Separating the two writers in the profile directory is what shows why intuitive fixes like "add a lock" or "use a random suffix" miss. Layer by layer:

  1. The boot path is one unconditional write: in prepareProfile() at @deepseek-ai/dsh/lib/profile-boot-DG5t9aNs.js:143:
js
writeFileSync(join(profile.dir, PROFILE_ROOT_FILENAME), PROFILE_ROOT_CONFIG); // PROFILE_ROOT_FILENAME === "cordis.yml"

No temp file, no rename, no lock, and executed unconditionally on every boot. This is the path this report is about (#441). 2. The other writer is the include plugin's write-back: @deepseek-ai/cordis-plugin-include/lib/index.js:243's _writeFile() uses "temp file plus rename" (fixed suffix, no lock). It does not go through the boot path, so changing that one to a random suffix or a lock cannot fix the reported problem — the community initially conflated these two writers and then corrected it point by point (#441). 3. The observable discriminator is the inode: a rename replaces the inode, an in-place write keeps it. Measured across one boot, inode=11562898 stays constant while mtime advances, and it has been the same inode across a week of daily scheduled boots. So the hazard is not "two processes racing over a shared .tmp filename" but O_TRUNC landing on a live file another process is reading — which explains why the reader sees an empty document while the file is intact afterwards (#441). 4. The two neighbouring files are already guarded: in the same prepareProfile, cordis.patch.yml and pnpm-workspace.yaml are both write-if-absent:

js
if (!existsSync(patchPath)) writeFileSync(patchPath, PROFILE_PATCH_TEMPLATE);
if (!existsSync(workspacePath)) writeFileSync(workspacePath, PROFILE_PNPM_WORKSPACE);

And the content written into cordis.yml is a compile-time constant (PROFILE_ROOT_CONFIG), so every boot rewrites it with what the file already has. Guarding cordis.yml the same way would close the window entirely, with no lock needed (#441). 5. But write-if-absent drops a guarantee: prepareProfile's own docstring explains why the rewrite is unconditional — the Loader's tree write-back can persist composed rows into cordis.yml, and leaving those in place would duplicate every bundle insert on the next boot. The correct shape is therefore "compare content, replace atomically only when it differs": a file that has been written over necessarily differs and is still restored, while every ordinary boot is a no-op and the truncate window disappears (#441). 6. A minor, separate issue: the include path's rename retry treats only EACCES / EBUSY / EPERM as retryable, so if two writers ever really do race there, the loser's ENOENT propagates instead of being retried. That is independent of this report's subject (#441).

Workaround and fix: DeepSeek Harness boot flock plus atomic write

The user-side workaround is cheap — serializing just the boot phase; the upstream fix is small too, and the community has already produced a branch. Specifically:

  1. The most practical workaround today: lock only the boot window. Since the rewrite happens in the first moments of startup, serializing that stretch is enough:
bash
exec 9>"$HOME/.dsh-boot.lock"
flock 9
dsh --profile headless "$TASK" &
child=$!
sleep 6          # cover the rewrite; the model-heavy remainder still overlaps
flock -u 9
wait "$child"

The measured effect: runs that previously lost one launch out of three became reliable. The lock is released a few seconds in and the model-heavy remainder still overlaps, so the cost is negligible — the community used this to cover 207 headless launches in a week (195 benchmark runs plus 12 scheduled daily inspections) with zero failed to validate config file failures (#441). 2. On failure, just retry: this is the upside of fail-fast — the process dies during loader init without executing any tool, and the config file is intact afterwards, so a plain restart suffices with no residue to clean up (#441). 3. Do not let multiple entry points trigger startup at once: for example a resident tray app plus a manual launch. Start only one instance per profile at a time, or use a lock file, or wait until the first instance is fully up before starting the second (#441). 4. A reference implementation of the upstream fix already exists: the branch ivanusto/deepseek-harness @ fix/profile-root-config-atomic-write (commit 8a493ae) makes prepareProfile compare before writing — an ordinary boot finds the content already correct and writes nothing, so the truncate window does not exist on that path; when the content really does differ, the replacement goes through a new writeFileAtomicSync in @deepseek-ai/dsh-atomic-write: exclusive-create a temp sibling, the caller's mode on the fresh inode, rename commit, temp removed on failure (#441). 5. Why a sync sibling rather than await writeFileAtomic: boot writes its config before the event loop carries any work, and making that path async would spread through every caller of the boot sequence for no behavioural gain; hand-rolling the replacement inside profile-boot.ts seemed worse — duplicated logic that would drift from the async version, and the repo's clone-detection gate would likely flag it (#441). 6. Acceptance bar (the branch's own testing): pnpm vitest run on the new and changed test files — 17 passed — with writeFileAtomicSync covered on the same paths as its async sibling (creation with parents and exact mode, narrowing a wider-permission file, replacing a symlinked target rather than writing through it, dirMode, no temp left behind when the rename fails); and the comparison covered by "unchanged inode and mtime when content matches", "restoration when the file holds baked rows", and "a changed inode proving the update arrived by rename rather than truncation". pnpm run lint and pnpm run typecheck came back clean, and pnpm run doc-sync passed 28 gates with 0 failed (#441). 7. Mind the contribution channel: the repo's CONTRIBUTING.md states that external pull requests are not accepted right now, so the author left the branch in place — if the shape is useful, the team needs only a cherry-pick rather than an implementation. The same lesson applies when you distribute plugins through DSH Plugin Hub that rewrite profile configuration themselves: any boot-time write to a shared file must account for concurrent readers, and the write must either be a no-op or be atomic (#441).

DSH plugin troubleshooting notes

Remember first that this is a race, not a config error — the file is empty only for a sub-millisecond instant, and the quickest discriminator between an in-place rewrite and a rename replacement is the inode. Eight points to keep in mind when a DeepSeek Harness plugin rewrites profile files at boot:

  1. The window is sub-millisecond: 25 seconds of 1 ms polling misses it; only a concurrent reader lands inside.
  2. Judge by inode: inode unchanged with mtime advancing means in-place rewrite; a changed inode means rename replacement.
  3. Do not conflate the two writers: the include write-back uses tmp+rename, boot uses an unconditional writeFileSync.
  4. Write-if-absent is not a complete fix: it drops the tree write-back cleanup guarantee.
  5. The right shape is compare-then-atomic-replace: a no-op on ordinary boots, atomically committed when content differs.
  6. The workaround only needs to lock boot: flock plus a few seconds, and the model-heavy phase can still overlap.
  7. Retry on failure: fail-fast means there are no side effects to clean up.
  8. Sync, not async: boot writes config before the event loop carries work, so async buys nothing.
DSH Plugin Hub plugin market: understand when a plugin rewrites profile config before installing or distributing it

Sources: Discussion #441, fix/profile-root-config-atomic-write.

FAQ

DeepSeek Harness concurrent launches fail intermittently, yet the config file is fine afterwards. How?

In DeepSeek Harness the cordis.yml file is only broken for an **extremely short window**, so an inspection afterwards finds it intact. prepareProfile() **writes cordis.yml in place** on every boot (O_TRUNC plus write, same inode, no temp file, no rename, no lock), so the file is momentarily zero-length on each boot. A second process that opens inside that window reads an empty document, fails the top-level-array check, and exits before the agent exists.

How long is that DeepSeek Harness window, and why do only concurrent launches hit it?

The DeepSeek Harness rewrite window is **sub-millisecond**, so only a reader that happens to open inside it sees it. Polling stat at 1 ms for 25 seconds (23,693 samples) never observed a size other than 223 bytes, so the window is tiny. The content itself never changes (the write is the fixed [] root config from profile initialization), so the problem is not "the content got corrupted" but "a reader observed a half-written state".

Would making the DeepSeek Harness write go through a locked or randomly suffixed temp file fix it?

No — that is not the path a DeepSeek Harness boot takes. The profile directory actually has **two writers**: @deepseek-ai/cordis-plugin-include's _writeFile() uses a temp file plus rename (fixed suffix, no lock), while boot goes through the **unconditional writeFileSync** in prepareProfile() and never touches the former. So changing the include path to a random suffix or a lock does not fix this report.

Why does the DeepSeek Harness fix suggestion compare-then-rename instead of simply "do not write when the file exists"?

The DeepSeek Harness fix suggests compare-then-atomic-rename because "do not write when it exists" drops a guarantee. prepareProfile's own docstring explains why the rewrite is unconditional: the Loader's tree write-back can **persist composed rows into cordis.yml**, and letting those stay would duplicate every bundle insert on the next boot. Comparing content keeps both — a file that has been written over necessarily differs, so it is still restored — while every ordinary boot becomes a no-op and the truncate window disappears.

Related Terms

in-place rewrite
Opening the same inode with O_TRUNC and writing, rather than writing a temp file and renaming. Its signature is that inode and size are unchanged across a boot while mtime advances; the cost is a zero-length window during the write, so a concurrent reader may see an empty document.https://github.com/deepseek-ai/deepseek-harness/discussions/441
prepareProfile
The boot-phase function that prepares the profile directory. It writes cordis.yml unconditionally, while the adjacent cordis.patch.yml and pnpm-workspace.yaml are already guarded with existsSync — that inconsistency is exactly the opening for the fix.https://github.com/deepseek-ai/deepseek-harness/discussions/441
fail-fast
The process exits during loader initialization, before any tool runs, so nothing unapproved executes. The cost is reliability rather than safety: a scheduled run is silently skipped unless the scheduler inspects exit codes.https://github.com/deepseek-ai/deepseek-harness/discussions/441

Sources