DeepSeek Harness market source returns after delete: race

TroubleshootingPublished 2026-10-30Author: DeepSeek Plugin Market
DeepSeek HarnessDSHplugin marketrace conditionmarket-storerefreshasync write-back
Deleting the last market source in DeepSeek Harness clears the list, then it returns later: an in-flight refresh writes back without checking its source.

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.

  1. Configure an index source A and load the plugin market normally.
  2. Trigger a refresh and keep the pluginMarket.search() response pending.
  3. At this point the read holds the token from when it departed.
  4. Normally, as soon as the token is incremented by a later operation, this return is judged stale.
  5. 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".

  1. Delete source A; setIndexes([]) and the following listIndexes() complete.
  2. The state is genuinely indexes: [], selected: null, listing: null at that moment.
  3. But the clearing action only changed the listing and never invalidated the in-flight read.
  4. Compare the other path: when selected !== current listing.url, a new load() starts, which increments the token and naturally stales the old read.
  5. The gap is exactly one branch wide: only the selected === null clearing 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.

  1. When the in-flight request returns it reaches settle(), which calls patch({ listing, indexes }).
  2. patch is a merge update, so indexes: [] and selected: null survive.
  3. Only listing is replaced with the old source's data, and url goes back to A.
  4. On success the state is status: 'ready'; on failure the old list is written back just the same, with status: 'error'.
  5. 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.

  1. The UI chooses the empty state by listing === null and otherwise renders the browse view.
  2. The restored list is not hidden, because selected and indexes are both empty.
  3. So what users see is "I deleted it and it came back".
  4. Note the boundary: this report does not claim the source was added back to config, nor that plugins were installed automatically.
  5. 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.

  1. Call reload() to re-read the source list, and the list returns to empty.
  2. Or destroy and rebuild the controller for the same effect.
  3. Clicking ensure() again will not clear it; it reuses the restored list.
  4. Refreshing again reports plugin-market/unknown-index, but the old list remains.
  5. 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.

  1. In the selected === null branch, execute this.listingRead++ so the in-flight read expires immediately.
  2. Immediately follow it with this.patch({ listing: null }).
  3. The order cannot be reversed, and it cannot sit after any await — one step late is as good as not done.
  4. Optional second safeguard: check in settle() whether the url is still in the config.
  5. This increment style matches the existing indexRead guard around loadIndexes, keeping the code consistent.
  6. For tests, follow the existing helpers: make the refresh pending, delete the source and assert empty, then resolve the pending result and wait for the microtask queue with setTimeout(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.

SymptomGapDiagnosis
Returns immediately after delete0 secondssettings written back to disk
Returns a few seconds latera waitin-flight refresh write-back
Still there after restartcross-processconfig or cache persistence
  1. Note the deletion time, then the time the list returns.
  2. If there was a wait, check whether a refresh was running during that window.
  3. 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.

  1. Do not read "the list returned" as "the source was re-added". The config layer stayed empty.
  2. Do not ignore the failure branch. Failure also writes the old list back, only marked as error.
  3. Do not use repeated refresh as a recovery method. It only reports unknown-index and keeps the old list.
  4. Mind the timing of the fix. Placing it after an await is the same as not fixing it.
  5. 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

After deleting the last source in the DeepSeek Harness plugin market, why does the list come back by itself?

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.

Was the source added back to the DeepSeek Harness config?

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.

Does a failed refresh also restore the list in DeepSeek Harness?

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.

How do I get the DeepSeek Harness plugin list back to a genuinely empty state?

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.

What is the fix for this in DeepSeek Harness?

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