DeepSeek Harness market source returns after delete: race
Delete the last market source, the list clears immediately, and the UI says "add your first source" — then a few seconds later that source's plugin list comes back in perfect order. The source config never changed: indexes is still an empty array and selected is still null, and only listing was overwritten. Split it into a timeline and you can see a classic race where "an in-flight request overwrites a user action" (#11103).
DeepSeek Harness: Before: a refresh departs with its read token
Every refresh records the current read token when it starts; on return it compares tokens and should discard a mismatch.
- Configure an index source A and load the plugin market normally.
- Trigger a refresh and keep the
pluginMarket.search()response pending. - At this point the read holds the token from when it departed.
- Normally, as soon as the token is incremented by a later operation, this return is judged stale.
- That is exactly the crux: whether it is stale depends on whether anyone increments that counter.
DSH plugin: When: the list is cleared but in-flight reads are not invalidated
loadIndexes() clears the listing when the selection is empty, but that branch does not increment the token, so the in-flight read is still "valid".
- Delete source A;
setIndexes([])and the followinglistIndexes()complete. - The state is genuinely
indexes: [],selected: null,listing: nullat that moment. - But the clearing action only changed the listing and never invalidated the in-flight read.
- Compare the other path: when
selected !== current listing.url, a newload()starts, which increments the token and naturally stales the old read. - The gap is exactly one branch wide: only the
selected === nullclearing path skips invalidation.
DeepSeek Harness: After: the result is written back unconditionally
settle() writes back into state using the url it captured at departure, without checking whether that source is still configured.
- When the in-flight request returns it reaches
settle(), which callspatch({ listing, indexes }). patchis a merge update, soindexes: []andselected: nullsurvive.- Only
listingis replaced with the old source's data, andurlgoes back to A. - On success the state is
status: 'ready'; on failure the old list is written back just the same, withstatus: 'error'. - Both success and failure reproduced 10/10 in a controlled environment, so this is not intermittent.
DSH Plugin: Why users can hit it in the UI
The source management menu stays available while the list loads, and the delete button is disabled only while "save sources" is in progress, so refresh → manage sources → delete is a reachable sequence.
- The UI chooses the empty state by
listing === nulland otherwise renders the browse view. - The restored list is not hidden, because
selectedandindexesare both empty. - So what users see is "I deleted it and it came back".
- Note the boundary: this report does not claim the source was added back to config, nor that plugins were installed automatically.
- It describes only the display layer being overwritten by old data.
DeepSeek Harness: Restoring a genuinely empty state
Re-read the source list or construct a new controller; clicking ensure() again does not help.
- Call
reload()to re-read the source list, and the list returns to empty. - Or destroy and rebuild the controller for the same effect.
- Clicking
ensure()again will not clear it; it reuses the restored list. - Refreshing again reports
plugin-market/unknown-index, but the old list remains. - These are API-level verifications and do not cover interactions such as clicking install on a stale plugin.
DSH plugin: Fix: increment the token in the same synchronous block
The community's minimal fix is to increment listingRead before clearing the listing on the loadIndexes() clearing branch; the position must be inside the same synchronous block.
- In the
selected === nullbranch, executethis.listingRead++so the in-flight read expires immediately. - Immediately follow it with
this.patch({ listing: null }). - The order cannot be reversed, and it cannot sit after any
await— one step late is as good as not done. - Optional second safeguard: check in
settle()whether the url is still in the config. - This increment style matches the existing
indexReadguard aroundloadIndexes, keeping the code consistent. - For tests, follow the existing helpers: make the refresh pending, delete the source and assert empty, then
resolvethe pending result and wait for the microtask queue withsetTimeout(resolve, 0)before asserting it is still empty.
One-minute check: which kind of return is this
Classify by the gap between deletion and return: zero delay is a config write-back, a wait is an in-flight write-back, and cross-process is persistence.
| Symptom | Gap | Diagnosis |
|---|---|---|
| Returns immediately after delete | 0 seconds | settings written back to disk |
| Returns a few seconds later | a wait | in-flight refresh write-back |
| Still there after restart | cross-process | config or cache persistence |
- Note the deletion time, then the time the list returns.
- If there was a wait, check whether a refresh was running during that window.
- If it survived a restart, check the settings file rather than in-memory state.
Cautions
Treat the return as a timing bug until the gap says otherwise.
- Do not read "the list returned" as "the source was re-added". The config layer stayed empty.
- Do not ignore the failure branch. Failure also writes the old list back, only marked as error.
- Do not use repeated refresh as a recovery method. It only reports
unknown-indexand keeps the old list. - Mind the timing of the fix. Placing it after an await is the same as not fixing it.
- Record the version and commit. The community reproduced it on both rc.3 and master, with the same path.
If your list returns immediately after deletion with no wait in between, this is not the race — check whether settings were written back to disk.See dsh plugin market not loading? Proxy, registry and 403 fixes for the related case.
FAQ
The list comes back because clearing it does not invalidate the in-flight refresh in DeepSeek Harness. loadIndexes() clears the listing when selected === null but does not increment the read token, so when the refresh returns it still calls settle() and writes the old source's list back.
No, the source was not added back to the DeepSeek Harness config: the write-back is a patch-style merge, so indexes: [] and selected: null are preserved and only listing returns. The display layer was overwritten; the config was not changed.
Yes, a failed refresh also restores the list in DeepSeek Harness: in a controlled environment the community measured 10/10 successes and 10/10 failures, and both write the old list back, differing only in whether status becomes ready or error.
To get a genuinely empty list in DeepSeek Harness, re-read the source list with reload(), or construct a new controller. Clicking ensure() again does not work because it reuses the restored list; refreshing again reports plugin-market/unknown-index while the old list remains.
The DeepSeek Harness fix is to increment listingRead in the same synchronous block where loadIndexes() clears the list so in-flight reads are invalidated; optionally add a source check in settle() as a second safeguard. The key is that it must not sit after any await.
Related Terms
- listingRead
- listingRead is the counter in the plugin market client that marks the listing read generation. A refresh records the current value at start and compares it on return; a mismatch means that read is stale.— https://github.com/deepseek-ai/deepseek-harness/discussions/11103
- settle()
- settle() is the function that writes a refresh result back into state; the current implementation writes directly using the url captured at start and does not check whether that source is still configured.— https://github.com/deepseek-ai/deepseek-harness/discussions/11103
- loadIndexes()
- loadIndexes() is the DeepSeek Harness plugin market function that reads the configured source list. It clears the listing when the selection is empty, but on that branch it does not invalidate an in-progress listing read.— https://github.com/deepseek-ai/deepseek-harness/discussions/11103
- in-flight
- An in-flight request is an asynchronous request that has been sent but not yet returned. If the user changes settings meanwhile, the result may no longer correspond to the current UI state.— https://github.com/deepseek-ai/deepseek-harness/discussions/11103
Sources
- #11103 — Discover restores a removed market when an in-flight refresh finishes· deepseek-ai (GitHub Discussions)
- deepseek-ai/deepseek-harness (source repository)· GitHub