Repository navigation
Replies: 4 comments
Supporting evidence: isolated reproduction, and the teardown/activation windowTwo things I can add to the report above. 1. It reproduces outside the Desktop app. I copied the runtime's So this does not depend on any Desktop-only component: it needs the 2. The slot is still occupied 23 ms into the replacement's construction. From the captured Host log (occurrence 1): The previous instance's agents are being disposed immediately before the replacement's constructor runs and finds the single-occupant slot taken. Re-writing the patch file with byte-identical content reproduces it again, and from then on every reload fails identically until the process restarts. What I tried, and what I could not confirm. I patched the shipped Attachments I can paste on request
Happy to test any candidate patch against this environment and report the result. |
Follow-up: four isolated experiments, and a correction to my own reportAfter filing this I tried to narrow the mechanism with isolated runs (scratch Experiment 1 — sequential reconstruct (dispose, then mount again), three times. Never fails. So a sequential rebuild with a normal dispose in between does not collide: Experiment 2 — overlapping instances (previous instance left alive, never disposed), two mounts. Identical for the shipped module and for a locally patched one. So "the slot is occupied while a replacement constructs" is not sufficient either. Experiment 3 — stale occupant with no disposer, then construct. This is the most informative result:
So a The one positive reproduction. The only setup that produced the production error was a composition where the loader itself owned the and in the production log the same throw arrives through Where that leaves it. What matters may not be the collision itself but that it is silent at the plugin level and only surfaces in the loader's activation accounting — where it then permanently blocks the entry (every later reload fails the same way until the process restarts). Two things I would now look at first:
I am reporting this as elimination plus one correction, not as a diagnosis. If it helps, the isolated harness is cheap to rebuild: copy the runtime |
Root cause: the disposer does run — its identity check can never succeedI hit this on I think I can close the gap you left open, and it is not a teardown-ordering race. The slot is released by the disposer, but the comparison inside it is dead code
registerAgentResolver(resolve: AgentResolver): () => void {
if (this.agentResolver !== undefined) throw new Error('file-upload: Agent resolver is already registered')
this.agentResolver = resolve
return () => {
if (this.agentResolver === resolve) this.agentResolver = undefined
}
}
// cordis/lib/index.js
else if (!tracker.noShadow && typeof innerValue === "function") {
shadow ??= createShadow(this.ctx, target, tracker.property, receiver)
return createShadowMethod(this.ctx, innerValue, receiver, shadow) // <- new Proxy on every read
}So Direct evidenceWith a breakpoint logpoint inside the disposer on a live host (one line before the comparison): So: the disposer is invoked, on the same instance that holds the resolver, and its guard sees a function that fails Corroborating, on the shipped bundle ( Minimal fix (validated)Bind the registration to a plain object instead of the function: plain objects are not re-wrapped by the traceable proxy, so identity survives. registerAgentResolver(resolve: AgentResolver): () => void {
if (this.agentResolver !== undefined) throw new Error('file-upload: Agent resolver is already registered')
// The disposer runs through a cordis traceable proxy, which re-wraps function-valued
// properties on every read, so `this.agentResolver === resolve` can never be true.
// A plain object is returned as-is, so bind the registration identity to one.
const token = {}
this.agentResolver = resolve
this.agentResolverToken = token
return () => {
if (this.agentResolverToken !== token) return
this.agentResolver = undefined
this.agentResolverToken = undefined
}
}(with How I validated itTwo scratch
After the patched reloads: 381/381 cordis fibres active, no failed entries, Two suggestions beyond the one-line fix
Since I found no reply here and the identical code still ships in |
|
Confirmed independently on Our trigger was replacing the JSONL persistence provider with a plugin-backed provider while the web host was running. The page continued loading, but creating a session returned A clean process initialized the controller successfully. More importantly, an isolated process reproduced the failure when we explicitly disposed the old persistence fiber before enabling its replacement. This does not require overlapping controller instances. Minimal identity regressionThis exercises the installed import assert from 'node:assert/strict';
import { resolve } from 'node:path';
import { pathToFileURL } from 'node:url';
// Set DSH_MODULES to the installed dsh/node_modules directory.
const modules = resolve(process.env.DSH_MODULES);
const load = name => import(pathToFileURL(`${modules}/@deepseek-ai/${name}/lib/index.js`).href);
const { Context, symbols, getTraceable } = await load('cordis');
const { FileUploads } = await load('dsh-client-file-upload');
const raw = Object.create(FileUploads.prototype);
Object.defineProperty(raw, symbols.tracker, { value: { property: 'ctx' } });
const service = getTraceable(new Context(), raw);
const dispose = service.registerAgentResolver(async () => {});
dispose();
assert.doesNotThrow(() => service.registerAgentResolver(async () => {}));Unpatched, the final assertion fails with the duplicate-resolver error. The disposer runs, but its function-identity comparison does not clear the slot. Patch testedWe stored the callback in a plain registration object, including the corresponding read in -private agentResolver: AgentResolver | undefined
+private agentResolver: { resolve: AgentResolver } | undefined
-this.agentResolver = resolve
+this.agentResolver = { resolve }
return () => {
- if (this.agentResolver === resolve) this.agentResolver = undefined
+ if (this.agentResolver?.resolve === resolve) this.agentResolver = undefined
}
-const resolver = this.agentResolver
+const resolver = this.agentResolver?.resolveFull-runtime regression resultsWe also added a test that boots the real shipped web profile in a temporary home, creates a session, disables and disposes the
We now run that regression during Nix package installation. The patched package was deployed to four hosts, and each successfully created a session through the HTTPS Contribution statusThe current contribution guide says external pull requests are not accepted yet, so no PR has been opened. The patch and regression evidence above are provided here for maintainers. We will retain the downstream patch until an upstream release passes the same regression without it. |
Uh oh!
There was an error while loading. Please reload this page.
Summary
On a long-lived Host with
dsh-hmrenabled, any write to the profile patch file ($DSH_HOME/profiles/<name>/cordis.patch.yml, or$DSH_HOME/cordis.patch.yml) triggers a config reload. Ifsession-controlleris rebuilt during that reload, the rebuild throws:SessionControllerfills that slot from its constructor:What I verified: if the previous
SessionControllerinstance is disposed before the replacement is constructed,ctx.effectreleases the slot correctly and re-activation succeeds — I exercised dispose-then-recreate repeatedly against the shipped bundle and never reproduced the error (the slot is released and re-registered cleanly each cycle). So the disposer itself works, and this is not a missing-cleanup bug insession-controller.What therefore must be happening on a real config reload: the replacement constructor runs while the previous instance's scope is still alive, so its resolver still occupies the slot, the guarded call throws, and the entry fails to activate. I could not pin the exact ordering from the outside — whether the old fiber's teardown is deferred past the new activation, or
ctx.effectteardown within that scope lands after the replacement's construction. That ordering is the thing to look at, in the profile-reload path (dsh-app-bootreconcileProfilePatches→ Loader entry update → fiber re-execution) or in Cordis' fiber update semantics.Why it never recovers: once the entry fails to activate, the next reload has the same stale occupant — and because the failing entry is the only thing that would have released it, every subsequent reload fails identically.
fileUploadsitself is not rebuilt by a profile-patch reload, so nothing else can clear the slot. Only a process restart does.Impact
A documented, user-space action — editing one's own profile patch — takes the entire session Remote API down until restart. Whatever the exact ordering fault, the observable contract is broken in a way that is both destructive and sticky.
Possible directions
dsh-app-bootreconcileProfilePatches→ Loader entry update → fiber re-execution).SessionControllercould exposeresolveAgentand letFileUploadsinject the service lazily, so a rebuild never needs to re-register anything.2 entries did not activateas a warning while the Remote API is already dead for every client).What I can provide
dsh-api-session-controlleranddsh-client-file-upload).Workaround (for other users hitting this)
cordis.patch.ymlwhile the app runs. Stage the file and restart instead.gateway/service-unavailable.sessionControllernever re-activates, so every later reload fails identically, and the whole session API is dead for the browser client:All reactions