Skip to content

fix(market): upgrade dshmarket to 1.15.0 - #141

Merged
yaojin3616 merged 15 commits into
mainfrom
fix/upgrade-dshmarket-1.15.0
Aug 21, 2026
Merged

fix(market): upgrade dshmarket to 1.15.0#141
yaojin3616 merged 15 commits into
mainfrom
fix/upgrade-dshmarket-1.15.0

Conversation

@yaojin3616

Copy link
Copy Markdown
Contributor

Bump default recommended dshmarket version from 1.9.0 to 1.15.0 and update market installer tests.

yaojin3616 and others added 15 commits August 21, 2026 19:03
On macOS Harness runs inside an Electron utility process, so its
process.execPath and argv0 point at the Electron helper rather than a Node
binary. Plugins re-invoke the dsh CLI through the executable running them —
dsh-market forwards process.execArgv with it — and that child booted as an
Electron app, where the leading --expose-internals shifts argv and the CLI
answers "error: --profile <name> is required". Updating a plugin from the
market therefore always failed on macOS ("Update failed: dshmarket").

The flag cannot travel in the Harness process environment: the utility
process is launched with Chromium switches Node rejects as bad options, so
setting it there stops Harness from starting at all (exit 2304). The Harness
entry declares Node mode from the inside instead, after its own switches are
parsed, marking only the children. The packaged node/pnpm shims declare it
themselves too, so a caller that scrubs the environment still gets Node
semantics.

Verified end to end on a dev launch: POST /dsh-market/update for dshmarket
returned exitCode 0 and moved the profile from 1.15.0 to 1.17.1.
Windows cannot replace a directory while something holds a handle inside
it, and pnpm finishes each package by renaming <pkg>_tmp_<pid>_<n> onto
<pkg>. With Harness running — it has the profile's modules loaded, and the
platform's scanners open files behind everyone's back — that final rename
fails, which is what "Update failed: dshmarket — EPERM ... rename
'…argparse_tmp_19856_4' -> '…argparse'" is. dshmarket 1.15.0 made this
reachable by gaining runtime dependencies (js-yaml -> argparse, undici)
where 1.9.0 had none.

pnpm is reached by name through the packaged shim by every profile package
operation — the desktop installer and the community market alike — so the
shim now points at a runner that owns the recovery for both: retry once (a
scanner's handle is gone within a second), then move the blocked directory
aside and let pnpm install over the freed name. Renaming the directory
itself succeeds where replacing its contents does not. Anything
unrecognized passes straight through with the same exit code and output.

The sidelined copies are swept alongside pnpm's staging directories before
Harness next starts, when nothing holds them.

Verified on macOS that install and failure propagation still behave through
the new shim path (dshmarket@1.15.0 installs; a bad spec still exits 1).
The recovery itself is covered by unit tests over the real failure text —
its effect needs a Windows run to confirm.
Two things the first cut of the runner got wrong, both found by a Windows
report that looked exactly like the one before it.

It could not be told apart from an unwrapped pnpm. The runner propagates
pnpm's own diagnostics verbatim when recovery does not help, so an
unchanged error message proved nothing about whether the runner ran at all.
Every step now announces itself on the same stream the market reports
verbatim: a failure report without "dsh-desktop pnpm runner:" lines is a
report from a pnpm this runner never wrapped, which is the first thing to
establish before reading anything else into it.

And it made a failing install slower — up to three pnpm runs where the
hosts already allow fifteen minutes for one, which is what a failed install
feeling like a hang is made of. A run that stops producing output for two
minutes (DSH_DESKTOP_PNPM_IDLE_TIMEOUT_MS) is now stopped rather than
waited out, and a run stopped that way is not retried: whatever wedged it
is still there. The Windows kill takes the process tree, since pnpm leaves
children behind.

A runner that cannot be staged no longer takes the shims down with it: pnpm
stays reachable without the recovery, and the harness log says which of the
two happened.
A pnpm run that dies partway through leaves a directory under the profile's
node_modules carrying a package's name without being one — the Windows
locked rename is the failure that keeps producing them. pnpm cannot rename
its staging directory onto that name afterwards, so the profile stays stuck
and every later attempt fails identically, whatever version of DSH Desktop
is installed over it. Upgrading has to be enough to get out of that state.

Nothing holds those directories before Harness starts, which makes the
launch path the one place they can be cleared safely — so that is where the
repair goes: clear the directories that are not packages, then reinstall the
profile's dependencies with Harness still stopped. Clearing alone would
amputate, since the packages are still wanted; the existing prune now runs
after the repair, so it only drops what the install could not restore.

An undamaged profile costs one directory scan. A repair failure is not
fatal: the prune still keeps the profile bootable, and both outcomes are
written to the Harness log, which is where someone diagnosing a failed
install is already looking.
The idle timeout could hang on exactly the platform it was written for.
killTree returned as soon as taskkill was spawned, so a taskkill that misses
left the runner waiting forever on an exit that never comes — the hang the
idle timeout exists to prevent. Windows CI caught it as a five-second test
timeout.

The direct kill is now the guarantee and taskkill only adds the tree, and a
killed run is written off after a grace period regardless, so no failure of
the kill itself can turn into a wait. Both paths are covered, with the kill
injected so the test states the behavior instead of the platform.

Also drop a directory symlink from the repair test onto a junction, which
Windows can create without Developer Mode or elevation.
main #142 upgraded DSH to 0.1.1-rc.1 and reworked the macOS utility-process
options into an object, which is where all three conflicts sat.

The disclaim decision is main's: it now travels as
`{ disclaim: !developmentBuild }` rather than this branch's boolean
parameter, so 5733b85's intent survives in main's shape and the branch drops
its own variant. The Harness-children fix on top of it is unaffected — it
never touched disclaim, only whether the children of the utility process are
addressable as Node.
The idle timeout fired on Windows against a live install. Silence was the
wrong signal: without a TTY pnpm drops its progress display, so resolution,
a cold download and a large link phase can each pass without a single line,
and two minutes of quiet is ordinary rather than wedged. A fail-fast that
turns a slow install into a failed one is worse than the wait it saves.

Liveness now also counts what a run cannot fake — changes under the profile
it installs into — and the allowance goes to five minutes of neither output
nor filesystem activity, still well inside the hosts' fifteen-minute ceiling
that made a wedged install feel like a hang. The report says which of the two
was missing, so a future firing is readable.

That the timeout fired at all is worth recording: only this runner prints
that line, so the packaged shim does reach it on Windows — which had been
the open question behind every identical-looking failure report so far.
The recovery was skipped in the one case it was written for. pnpm raises the
locked rename inside a worker and does not always unwind from it, so the run
reports the blocker and then stops responding — and the runner, having had
to stop it, treated it as undiagnosable and returned without moving anything
aside. A stopped run says nothing about whether its blocker can be cleared;
what decides that is whether the output names one, so that is what the
branch tests now.

Being stopped for silence is also no longer the same event as failing. Once
the blocked rename is in the output the outcome is decided, so the run gets
twenty seconds to exit on its own (DSH_DESKTOP_PNPM_FAILURE_STALL_MS) rather
than the five-minute idle allowance meant for a run nobody can read yet.

The two-minute silence reported from Windows was this: dsh-market always
passes --reporter=ndjson, which narrates every step, so a run that says
nothing for that long has stopped working rather than gone quiet — the
worker had already died on the rename.
The Windows log showed the repair running against a live Harness: on a
restart, launchHarness() repaired before runtime.start(), and start() is
what stops the previous process. So the one step that assumes nothing holds
the profile ran in exactly the condition it exists to avoid, and its
reinstall failed — clearing three damaged directories without putting the
packages back, which leaves the profile worse than it found it. The launch
path now stops Harness itself before repairing.

Two things the same log made visible:

The desktop and the Harness-side installer both write shims into
<dshHome>/.desktop-bin, but only one of them routed pnpm through the
lock-recovery runner. A desktop-written shim replaced a runner-routed pnpm
with a plain one, silently dropping the recovery until Harness next rewrote
them. Both writers now share the same command.

And a failed repair reported "dsh: pnpm failed in profile directory …" —
dsh's own wrapper line, which is always last and names no cause. The line
that names one is reported instead, so the next failure is readable.
Updating a plugin never succeeded on a Windows account whose name is not
ASCII. `C:\Users\数据项素\...` is every path a profile has, and Node's
recursive `rm` removes nothing under such a path while still resolving
successfully:

  rmSync('C:\Users\Public\ascii', { recursive: true }) -> gone
  rmSync('C:\Users\Public\中文',  { recursive: true }) -> still there

That is the whole failure. Replacing a package means renaming a staging
directory onto an existing one, which Windows refuses outright; pnpm's
recovery deletes the destination first and retries for a minute, and its
delete is `fs.rmSync(p, { recursive: true, force: true })` — so the
destination never goes away and all sixty seconds are spent losing. Each
blocked package costs a minute before ERR_PNPM_EPERM, and the desktop's
own sweeps were the same call: one reported twelve cleared directories
over a profile where all twelve were still on disk.

Every cleanup now walks the tree itself — unlink each file, rmdir on the
way out, symlinks detached rather than followed — and confirms the path
is gone before reporting it. The sweeps reach into a package's own
node_modules too, where a replaced dependency of a dependency stages.

Two things that kept the escape hatches shut are opened with it. The
runner frees every destination the failed run staged for instead of only
the one pnpm named, because an update blocks on several at once and one
per attempt cannot finish inside three. And the launch repair stops
asking for a frozen lockfile: a `pnpm add` that dies while linking has
already written the new version into pnpm-lock.yaml while package.json
still names the old one, so freezing there fails on the divergence that
the repair exists to resolve.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@yaojin3616
yaojin3616 merged commit 1df9c30 into main Aug 21, 2026
4 checks passed
@yaojin3616
yaojin3616 deleted the fix/upgrade-dshmarket-1.15.0 branch August 21, 2026 17:00
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.

1 participant