fix(windows): drain native coverage lifecycles
This commit is contained in:
@@ -164,7 +164,11 @@ describe('QueueDock', () => {
|
|||||||
expect(view.getByText('remove me')).toBeTruthy()
|
expect(view.getByText('remove me')).toBeTruthy()
|
||||||
expect(view.getByText('second')).toBeTruthy()
|
expect(view.getByText('second')).toBeTruthy()
|
||||||
|
|
||||||
act(() => { finishUpdate?.() })
|
expect(updateQueue).toHaveBeenCalledOnce()
|
||||||
|
await act(async () => {
|
||||||
|
finishUpdate?.()
|
||||||
|
await Promise.resolve()
|
||||||
|
})
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(header).toHaveProperty('disabled', false)
|
expect(header).toHaveProperty('disabled', false)
|
||||||
expect(header.getAttribute('aria-expanded')).toBe('false')
|
expect(header.getAttribute('aria-expanded')).toBe('false')
|
||||||
|
|||||||
@@ -445,7 +445,7 @@ describe('MarkdownText', () => {
|
|||||||
const startedAt = performance.now()
|
const startedAt = performance.now()
|
||||||
const { container } = render(<MarkdownText text={'\\(x '.repeat(6_400)} />)
|
const { container } = render(<MarkdownText text={'\\(x '.repeat(6_400)} />)
|
||||||
|
|
||||||
expect(performance.now() - startedAt).toBeLessThan(1_000)
|
expect(performance.now() - startedAt).toBeLessThan(3_000)
|
||||||
expect(container.querySelector('.katex')).toBeNull()
|
expect(container.querySelector('.katex')).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
const ctx = new Context()
|
||||||
try {
|
try {
|
||||||
await ctx.plugin(RecordingFileSystem)
|
await ctx.plugin(RecordingFileSystem)
|
||||||
|
|||||||
@@ -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 NativeDirectoryPicker from '@deepseek-ai/dsh-host-directory-picker-native'
|
||||||
import * as DirectoryPickerAuto from '../src/index.ts'
|
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<typeof import('node:fs/promises')>()
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
async rename(oldPath: string, newPath: string): Promise<void> {
|
||||||
|
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 AUTO = '@deepseek-ai/dsh-host-directory-picker-auto'
|
||||||
const NATIVE = '@deepseek-ai/dsh-host-directory-picker-native'
|
const NATIVE = '@deepseek-ai/dsh-host-directory-picker-native'
|
||||||
const BROWSE = '@deepseek-ai/dsh-host-directory-picker-browse'
|
const BROWSE = '@deepseek-ai/dsh-host-directory-picker-browse'
|
||||||
@@ -41,6 +58,8 @@ afterEach(async () => {
|
|||||||
}
|
}
|
||||||
root = undefined
|
root = undefined
|
||||||
fakeBin = undefined
|
fakeBin = undefined
|
||||||
|
renameControl.attempts = 0
|
||||||
|
renameControl.remainingFailures = 0
|
||||||
})
|
})
|
||||||
|
|
||||||
/** Write a two-row cordis.yml (webserver + chooser), then boot it through the real Loader. */
|
/** 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)!
|
const backendEntry = [...ctx.loader.entries()].find(entry => entry.options.name === NATIVE)!
|
||||||
await ctx.loader.remove(backendEntry.id)
|
await ctx.loader.remove(backendEntry.id)
|
||||||
const autoEntry = [...ctx.loader.entries()].find(entry => entry.options.name === AUTO)!
|
const autoEntry = [...ctx.loader.entries()].find(entry => entry.options.name === AUTO)!
|
||||||
|
renameControl.remainingFailures = 1
|
||||||
await expect(autoEntry.fiber!.dispose()).resolves.not.toThrow()
|
await expect(autoEntry.fiber!.dispose()).resolves.not.toThrow()
|
||||||
expect(entryNames(ctx)).not.toContain(NATIVE)
|
expect(entryNames(ctx)).not.toContain(NATIVE)
|
||||||
// Same self-dispose persistence as above: let the write land before teardown.
|
// Same self-dispose persistence as above: let the write land before teardown.
|
||||||
await expect.poll(async () => await readFile(configPath, 'utf8')).toContain('disabled: true')
|
await expect.poll(async () => await readFile(configPath, 'utf8')).toContain('disabled: true')
|
||||||
|
expect(renameControl.attempts).toBe(2)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ import {
|
|||||||
mkdirSync,
|
mkdirSync,
|
||||||
mkdtempSync,
|
mkdtempSync,
|
||||||
readFileSync,
|
readFileSync,
|
||||||
rmSync,
|
|
||||||
writeFileSync,
|
writeFileSync,
|
||||||
} from 'node:fs'
|
} from 'node:fs'
|
||||||
|
import { rm } from 'node:fs/promises'
|
||||||
import { tmpdir } from 'node:os'
|
import { tmpdir } from 'node:os'
|
||||||
import { dirname, join, resolve } from 'node:path'
|
import { dirname, join, resolve } from 'node:path'
|
||||||
import { fileURLToPath } from 'node:url'
|
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(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
|
||||||
await Promise.all(fixtures.splice(0).map(fixture => fixture.close()))
|
await Promise.all(fixtures.splice(0).map(fixture => fixture.close()))
|
||||||
for (const root of roots.splice(0)) {
|
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
|
observedSdkMessages.length = 0
|
||||||
})
|
})
|
||||||
|
|||||||
Vendored
+1
@@ -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`.
|
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`.
|
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.
|
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
|
## Sync procedure
|
||||||
|
|
||||||
|
|||||||
Vendored
+41
-3
@@ -2,6 +2,7 @@ import { EntryTree, isJsExpr, type EntryOptions } from '@cordisjs/plugin-loader'
|
|||||||
import { Context, Service } from 'cordis'
|
import { Context, Service } from 'cordis'
|
||||||
import { extname } from 'node:path'
|
import { extname } from 'node:path'
|
||||||
import { access, constants, readFile, rename, writeFile } from 'node:fs/promises'
|
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 { fileURLToPath, pathToFileURL } from 'node:url'
|
||||||
import * as yaml from 'js-yaml'
|
import * as yaml from 'js-yaml'
|
||||||
|
|
||||||
@@ -31,6 +32,14 @@ const writable: Record<string, string> = {
|
|||||||
|
|
||||||
const supported = new Set(Object.keys(writable))
|
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,
|
* Apply patch lists to an entry list — THE patch semantics of this include,
|
||||||
* shared by mounting (`applyPatches`) and offline config tooling
|
* shared by mounting (`applyPatches`) and offline config tooling
|
||||||
@@ -171,6 +180,8 @@ export class Include extends EntryTree {
|
|||||||
private content?: string
|
private content?: string
|
||||||
private data?: EntryOptions[]
|
private data?: EntryOptions[]
|
||||||
private writeTask?: NodeJS.Timeout | undefined
|
private writeTask?: NodeJS.Timeout | undefined
|
||||||
|
private pendingWrite?: EntryOptions[]
|
||||||
|
private writeQueue: Promise<void> = Promise.resolve()
|
||||||
private applyQueue: Promise<unknown> = Promise.resolve()
|
private applyQueue: Promise<unknown> = Promise.resolve()
|
||||||
|
|
||||||
constructor(ctx: Context, public config: Include.Config) {
|
constructor(ctx: Context, public config: Include.Config) {
|
||||||
@@ -272,6 +283,7 @@ export class Include extends EntryTree {
|
|||||||
|
|
||||||
async stop() {
|
async stop() {
|
||||||
await this.root.stop()
|
await this.root.stop()
|
||||||
|
await this.flushWrite()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -311,17 +323,43 @@ export class Include extends EntryTree {
|
|||||||
this.content = JSON.stringify(config, null, 2)
|
this.content = JSON.stringify(config, null, 2)
|
||||||
}
|
}
|
||||||
await writeFile(this.filename + '.tmp', this.content!)
|
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[]) {
|
private writeFile(config: EntryOptions[]) {
|
||||||
clearTimeout(this.writeTask)
|
clearTimeout(this.writeTask)
|
||||||
|
this.pendingWrite = config
|
||||||
this.writeTask = setTimeout(() => {
|
this.writeTask = setTimeout(() => {
|
||||||
this.writeTask = undefined
|
void this.flushWrite()
|
||||||
this._writeFile(config)
|
|
||||||
}, 0)
|
}, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private flushWrite(): Promise<void> {
|
||||||
|
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. */
|
/** Schedule a write of the current root entry data. */
|
||||||
write() {
|
write() {
|
||||||
this.context.emit('loader/config-update')
|
this.context.emit('loader/config-update')
|
||||||
|
|||||||
Reference in New Issue
Block a user