mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
fix(hmr,include): settle a failing boot instead of a silent exit 13
Master's transactional loader made the invalid-provider PTY case regress: the HMR main watcher's initial scan refreshed the include mid-initial-apply, the concurrent group updates stranded the include fiber, and once serialized the failing apply's rollback deadlocked on HMR's refresh drain — dsh exited 13 with no diagnostic and the terminal stranded, the exact symptom this branch fixes. Serialize every include child-tree mutation through one queue and pass ignoreInitial to the HMR main watcher; the failing boot now settles through boot()'s labelled rejection with the tree disposed and exit 1. The PTY case asserts the settled diagnostic; the fail-loud release remains the guard for rejections boot cannot see.
This commit is contained in:
Vendored
+1
@@ -42,6 +42,7 @@ Keep this log exhaustive — every divergence from upstream must be listed.
|
||||
10. **`loader/src/repository.ts`, `loader/tsdown.config.ts`, and the `@cordisjs/plugin-loader/repository` export**: the Node-only `RepositoryCache` installs one exact dependency specifier through the bundled `pnpm@11.7.0`, single-flights callers, and atomically publishes only a prepared package plus marker under the specifier hash. The subpath stays out of the browser-reachable Loader entry. Identical specifiers permanently reuse that entry; callers change the ref/specifier for another generation. The isolated workspace permits dependency build scripts because a configured repository is executable code, while the child drops ambient credential-shaped variables. Covered by `packages/ui/app-boot/tests/repository-cache.spec.ts`, including a keyless local-Git prepare run through the bundled pnpm.
|
||||
11. **Vendored Node-compatible TypeScript**: marked erased imports explicitly across `cordis`, `loader`, `include`, `hmr`, and `schemastery` so Node's native TypeScript transform does not request types as runtime exports. Schemastery's source uses an ESM default export and its package declares `type: module`; its built ESM/CJS entries retain explicit `.mjs`/`.cjs` extensions.
|
||||
12. **`include/src/index.ts` patch-semantics export**: extracted the private `applyPatches` body into the exported pure function `applyEntryPatches(data, patches, warn)` (the method delegates to it) and exported the `!!js` YAML dialect as `entryListSchema`, so `dsh --dump-config` composes and prints exactly what the include would mount without booting a tree. Behavior-preserving for mounting; the extraction exists because config tooling must never reimplement (and drift from) the patch algorithm. `applyEntryPatches` also indexes each `insert`ed entry as it is added, so a later patch in the same list can configure or disable a row an earlier patch inserted; upstream built the id index once before the patch loop, leaving inserted rows silently unpatchable. That matters because `dsh` composes one shared base (`apps/cli/config/base.cordis.yml`) with a surface overlay, an optional `--config` overlay, and the personal `~/.dsh/config.yaml` as sibling patch lists at one include level — patches never cross an include boundary, so surface-only rows would otherwise be unreachable from user config. Covered by `packages/ui/app-boot/tests/config-reload.spec.ts`.
|
||||
13. **`include/src/index.ts` serialized child-tree mutation and `hmr/src/index.ts` main-watcher initial-scan suppression**: every Include child-tree mutation (initial apply, refresh, `internal/update` patch re-application) runs through one per-Include queue, because the group's transactional `update` is not reentrant — two concurrent applies interleave create and rollback on the same entries and strand the Include fiber without ever settling. The HMR main watcher passes `ignoreInitial: true`: the initial scan re-announced files boot had just consumed, and its `add` for a config file refreshed an Include mid-initial-apply; once serialized, a failing initial apply's rollback disposed HMR, whose teardown drain waited on the queued refresh sitting behind that same apply — a deadlock that exited 13 with no diagnostic and the TUI's terminal state stranded. `registerConfig()` keeps its own `ignoreInitial: false` watcher because a personal config present at registration must apply once. Covered by the `dsh` invalid-provider PTY case in `apps/cli/tests/tui-keyless-smoke.e2e.ts`.
|
||||
|
||||
## Sync procedure
|
||||
|
||||
|
||||
Vendored
+8
@@ -209,6 +209,14 @@ class Hmr extends Service {
|
||||
...this.config,
|
||||
cwd: this.baseDir,
|
||||
ignored: path => match(relative(this.baseDir, path)),
|
||||
// The initial scan re-announces files the boot just consumed: an `add`
|
||||
// for a config file refreshes an include whose initial apply may still
|
||||
// be in flight, and a failing apply then rolls this plugin back while
|
||||
// the scan-triggered refresh waits on that apply — a teardown deadlock
|
||||
// that strands boot without a diagnostic. Only events after the scan
|
||||
// matter here; `registerConfig` keeps its own initial scan because a
|
||||
// personal config present at registration must apply once.
|
||||
ignoreInitial: true,
|
||||
})
|
||||
|
||||
// Collect externals: framework modules reachable from the main entry.
|
||||
|
||||
Vendored
+33
-7
@@ -171,6 +171,7 @@ export class Include extends EntryTree {
|
||||
private content?: string
|
||||
private data?: EntryOptions[]
|
||||
private writeTask?: NodeJS.Timeout
|
||||
private applyQueue: Promise<unknown> = Promise.resolve()
|
||||
|
||||
constructor(ctx: Context, public config: Include.Config) {
|
||||
super(ctx)
|
||||
@@ -186,12 +187,29 @@ export class Include extends EntryTree {
|
||||
|
||||
ctx.on('internal/update', async (config, _, next) => {
|
||||
if (config.path !== this.config.path) return next()
|
||||
const data = this.applyPatches(this.data!, config.patches)
|
||||
await this.root.update(data)
|
||||
this.config = config
|
||||
await this.enqueue(async () => {
|
||||
const data = this.applyPatches(this.data!, config.patches)
|
||||
await this.root.update(data)
|
||||
this.config = config
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize one child-tree mutation behind every earlier one. The group's
|
||||
* transactional `update` is not reentrant: two concurrent applies (the init
|
||||
* apply racing an HMR-triggered refresh from the watcher's initial scan)
|
||||
* interleave create and rollback on the same entries and strand the include
|
||||
* fiber without settling, so every apply path funnels through this queue.
|
||||
* A predecessor's failure is its own caller's outcome and never gates the
|
||||
* next task.
|
||||
*/
|
||||
private enqueue<T>(task: () => Promise<T>): Promise<T> {
|
||||
const run = this.applyQueue.then(task, task)
|
||||
this.applyQueue = run.then(() => {}, () => {})
|
||||
return run
|
||||
}
|
||||
|
||||
private async checkAccess() {
|
||||
if (!this.type) return
|
||||
try {
|
||||
@@ -262,12 +280,20 @@ export class Include extends EntryTree {
|
||||
* @throws when reading, parsing, validation, application, or rollback fails; the last good tree remains active when rollback succeeds.
|
||||
*/
|
||||
async refresh() {
|
||||
const candidate = await this.read()
|
||||
if (!candidate) return
|
||||
await this.apply(candidate)
|
||||
// Read inside the queue so the changed-content check compares against the
|
||||
// predecessor's committed state, not a mid-apply snapshot.
|
||||
await this.enqueue(async () => {
|
||||
const candidate = await this.read()
|
||||
if (!candidate) return
|
||||
await this._apply(candidate)
|
||||
})
|
||||
}
|
||||
|
||||
private async apply(candidate: ReadCandidate) {
|
||||
private apply(candidate: ReadCandidate) {
|
||||
return this.enqueue(() => this._apply(candidate))
|
||||
}
|
||||
|
||||
private async _apply(candidate: ReadCandidate) {
|
||||
const data = this.applyPatches(candidate.data, this.config.patches)
|
||||
await this.root.update(data)
|
||||
this.content = candidate.content
|
||||
|
||||
Reference in New Issue
Block a user