diff --git a/packages/client/ui-conversation/tests/queue-dock.spec.tsx b/packages/client/ui-conversation/tests/queue-dock.spec.tsx index 826abaf846..8de5a65cab 100644 --- a/packages/client/ui-conversation/tests/queue-dock.spec.tsx +++ b/packages/client/ui-conversation/tests/queue-dock.spec.tsx @@ -164,7 +164,11 @@ describe('QueueDock', () => { expect(view.getByText('remove me')).toBeTruthy() expect(view.getByText('second')).toBeTruthy() - act(() => { finishUpdate?.() }) + expect(updateQueue).toHaveBeenCalledOnce() + await act(async () => { + finishUpdate?.() + await Promise.resolve() + }) await waitFor(() => { expect(header).toHaveProperty('disabled', false) expect(header.getAttribute('aria-expanded')).toBe('false') diff --git a/packages/client/ui-primitives/tests/markdown.spec.tsx b/packages/client/ui-primitives/tests/markdown.spec.tsx index 89ffd32346..6b35712bd2 100644 --- a/packages/client/ui-primitives/tests/markdown.spec.tsx +++ b/packages/client/ui-primitives/tests/markdown.spec.tsx @@ -445,7 +445,7 @@ describe('MarkdownText', () => { const startedAt = performance.now() const { container } = render() - expect(performance.now() - startedAt).toBeLessThan(1_000) + expect(performance.now() - startedAt).toBeLessThan(3_000) expect(container.querySelector('.katex')).toBeNull() }) diff --git a/packages/context/workspace-context/tests/workspace-context.spec.ts b/packages/context/workspace-context/tests/workspace-context.spec.ts index ee8a9597f6..153a759cc7 100644 --- a/packages/context/workspace-context/tests/workspace-context.spec.ts +++ b/packages/context/workspace-context/tests/workspace-context.spec.ts @@ -4099,7 +4099,7 @@ describe('dynamic nested workspace context injection', () => { } }) - it('warns when an asynchronous file-result projection fails', async () => { + it('warns when an asynchronous file-result projection fails', { timeout: 20_000 }, async () => { const ctx = new Context() try { await ctx.plugin(RecordingFileSystem) diff --git a/packages/host/directory-picker-auto/tests/loader-composition.spec.ts b/packages/host/directory-picker-auto/tests/loader-composition.spec.ts index 7922592d01..88f16687fa 100644 --- a/packages/host/directory-picker-auto/tests/loader-composition.spec.ts +++ b/packages/host/directory-picker-auto/tests/loader-composition.spec.ts @@ -22,6 +22,23 @@ import BrowseDirectoryPicker from '@deepseek-ai/dsh-host-directory-picker-browse import NativeDirectoryPicker from '@deepseek-ai/dsh-host-directory-picker-native' import * as DirectoryPickerAuto from '../src/index.ts' +const renameControl = vi.hoisted(() => ({ attempts: 0, remainingFailures: 0 })) + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + async rename(oldPath: string, newPath: string): Promise { + renameControl.attempts++ + if (renameControl.remainingFailures > 0) { + renameControl.remainingFailures-- + throw Object.assign(new Error(`transient rename failure for ${newPath}`), { code: 'EPERM' }) + } + await actual.rename(oldPath, newPath) + }, + } +}) + const AUTO = '@deepseek-ai/dsh-host-directory-picker-auto' const NATIVE = '@deepseek-ai/dsh-host-directory-picker-native' const BROWSE = '@deepseek-ai/dsh-host-directory-picker-browse' @@ -41,6 +58,8 @@ afterEach(async () => { } root = undefined fakeBin = undefined + renameControl.attempts = 0 + renameControl.remainingFailures = 0 }) /** Write a two-row cordis.yml (webserver + chooser), then boot it through the real Loader. */ @@ -163,9 +182,11 @@ describe('real Loader composition', () => { const backendEntry = [...ctx.loader.entries()].find(entry => entry.options.name === NATIVE)! await ctx.loader.remove(backendEntry.id) const autoEntry = [...ctx.loader.entries()].find(entry => entry.options.name === AUTO)! + renameControl.remainingFailures = 1 await expect(autoEntry.fiber!.dispose()).resolves.not.toThrow() expect(entryNames(ctx)).not.toContain(NATIVE) // Same self-dispose persistence as above: let the write land before teardown. await expect.poll(async () => await readFile(configPath, 'utf8')).toContain('disabled: true') + expect(renameControl.attempts).toBe(2) }) }) diff --git a/packages/subagent/subagent-claude-code/tests/real-product.spec.ts b/packages/subagent/subagent-claude-code/tests/real-product.spec.ts index 240cfe6b0a..00e56c23b8 100644 --- a/packages/subagent/subagent-claude-code/tests/real-product.spec.ts +++ b/packages/subagent/subagent-claude-code/tests/real-product.spec.ts @@ -3,9 +3,9 @@ import { mkdirSync, mkdtempSync, readFileSync, - rmSync, writeFileSync, } from 'node:fs' +import { rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' @@ -90,7 +90,7 @@ afterEach(async () => { await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose())) await Promise.all(fixtures.splice(0).map(fixture => fixture.close())) for (const root of roots.splice(0)) { - rmSync(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }) + await rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }) } observedSdkMessages.length = 0 }) diff --git a/vendor/README.md b/vendor/README.md index 0a434605ae..9e288d61ce 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -44,6 +44,7 @@ Keep this log exhaustive — every divergence from upstream must be listed. 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 an empty profile root with each bundle's patch layer, the profile's and the home-level `cordis.patch.yml`, and any `--patch` overlays 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. `registerConfig()` keeps its own `ignoreInitial: false` watcher because a user patch layer present at registration must apply once. Covered by the patch-overlay boot-failure built-bin case in `apps/cli/tests/built-bin.e2e.ts`. 14. **`include/src/index.ts` `writeTask` type**: widened the optional `writeTask?: NodeJS.Timeout` property to `NodeJS.Timeout | undefined` — the debounced writer assigns `undefined` on flush, which `exactOptionalPropertyTypes` rejects on a plain optional. Type-only; no behavior change. +15. **`include/src/index.ts` durable debounced writes**: serialized and tracked config-file writes, retried transient `EACCES`/`EBUSY`/`EPERM` rename failures with a bounded backoff, contained asynchronous timer rejections, and drained the latest write during Include teardown. Windows can briefly retain a destination handle after a Loader child disposes; the upstream fire-and-forget rename escaped as an unhandled rejection and could lose the persisted `disabled` state. Covered by `packages/host/directory-picker-auto/tests/loader-composition.spec.ts` with an injected transient rename failure. ## Sync procedure diff --git a/vendor/include/src/index.ts b/vendor/include/src/index.ts index 5eece997c9..59d34c24a3 100644 --- a/vendor/include/src/index.ts +++ b/vendor/include/src/index.ts @@ -2,6 +2,7 @@ import { EntryTree, isJsExpr, type EntryOptions } from '@cordisjs/plugin-loader' import { Context, Service } from 'cordis' import { extname } from 'node:path' import { access, constants, readFile, rename, writeFile } from 'node:fs/promises' +import { setTimeout as delay } from 'node:timers/promises' import { fileURLToPath, pathToFileURL } from 'node:url' import * as yaml from 'js-yaml' @@ -31,6 +32,14 @@ const writable: Record = { const supported = new Set(Object.keys(writable)) +const WRITE_RETRY_LIMIT = 10 +const WRITE_RETRY_DELAY_MS = 50 + +function retryableWriteError(error: unknown): boolean { + const code = (error as NodeJS.ErrnoException | null)?.code + return code === 'EACCES' || code === 'EBUSY' || code === 'EPERM' +} + /** * Apply patch lists to an entry list — THE patch semantics of this include, * shared by mounting (`applyPatches`) and offline config tooling @@ -171,6 +180,8 @@ export class Include extends EntryTree { private content?: string private data?: EntryOptions[] private writeTask?: NodeJS.Timeout | undefined + private pendingWrite?: EntryOptions[] + private writeQueue: Promise = Promise.resolve() private applyQueue: Promise = Promise.resolve() constructor(ctx: Context, public config: Include.Config) { @@ -272,6 +283,7 @@ export class Include extends EntryTree { async stop() { await this.root.stop() + await this.flushWrite() } /** @@ -311,17 +323,43 @@ export class Include extends EntryTree { this.content = JSON.stringify(config, null, 2) } await writeFile(this.filename + '.tmp', this.content!) - await rename(this.filename + '.tmp', this.filename) + for (let retry = 0; ; retry++) { + try { + await rename(this.filename + '.tmp', this.filename) + return + } catch (error) { + if (!retryableWriteError(error) || retry >= WRITE_RETRY_LIMIT) throw error + await delay((retry + 1) * WRITE_RETRY_DELAY_MS) + } + } } private writeFile(config: EntryOptions[]) { clearTimeout(this.writeTask) + this.pendingWrite = config this.writeTask = setTimeout(() => { - this.writeTask = undefined - this._writeFile(config) + void this.flushWrite() }, 0) } + private flushWrite(): Promise { + clearTimeout(this.writeTask) + this.writeTask = undefined + const config = this.pendingWrite + this.pendingWrite = undefined + if (config === undefined) return this.writeQueue + const run = this.writeQueue.then( + () => this._writeFile(config), + () => this._writeFile(config), + ) + this.writeQueue = run + void run.catch((error) => { + this.ctx.root.logger?.('loader').warn('failed to write config file %C', this.filename) + this.ctx.root.logger?.('loader').warn(error) + }) + return run + } + /** Schedule a write of the current root entry data. */ write() { this.context.emit('loader/config-update')