fix(windows): drain native coverage lifecycles

This commit is contained in:
Tianyi Cui
2026-08-08 23:56:00 +08:00
parent 0aefa18636
commit 3a518021f6
7 changed files with 72 additions and 8 deletions
+1
View File
@@ -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
+41 -3
View File
@@ -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<string, string> = {
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<void> = Promise.resolve()
private applyQueue: Promise<unknown> = 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<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. */
write() {
this.context.emit('loader/config-update')