fix(windows): canonicalize native watch paths

This commit is contained in:
Tianyi Cui
2026-08-08 19:29:43 +08:00
parent a6c129a3c6
commit 702e2d024a
21 changed files with 157 additions and 36 deletions
+1 -1
View File
@@ -38,7 +38,7 @@ Keep this log exhaustive — every divergence from upstream must be listed.
6. **`cordis/src/fiber.ts` lifecycle hardening**: locally closes three reentrant disposal gaps. An effect's owner-list wrapper is registered before its setup body runs, so an unload begun from inside setup awaits setup and every collected cleanup; synchronous setup failure removes the wrapper and rolls back collected cleanup. Async cleanup stays owner-visible until quiescence, and Cordis's internal effect composition joins an already-running cleanup while repeated public disposer calls retain their upstream single-shot result. Effect creation is rejected while the owner is `UNLOADING` (while `PENDING` and `LOADING` remain legal), preventing cleanup-time registrations from escaping the unload snapshot. Child fibers register and receive their parent-owned disposer before `internal/plugin` publication, resolve dependency declarations added by that notification before activation, drain effects attached while pending, skip plugin execution when reentrant disposal invalidates the load epoch before its first checkpoint, and contain teardown-notification failures per observer so one callback cannot starve peers or interrupt ownership cleanup. `Fiber.update()` returns its `internal/update` waterfall result, allowing Loader callers to await a restart while preserving synchronous config validation.
7. **`cordis/src/*.ts` JSDoc enrichment**: added `@param`/`@returns` tags and contract documentation (disposal semantics, waterfall veto, bail conditions, error cases) across the public plugin-author surface — `Context` (class, statics, and the `Context` interface properties incl. `root`), `EventsService`, `Fiber`, `RegistryService`, `ReflectService`, `Service`, `LoggerService` and their `declare module './context.ts'` overloads. Comment-only; no code changes. Motivation: the website API-reference generator renders these docs and hard-errors on undocumented members. Retire this entry when the enrichment is upstreamed to the fork.
8. **Transactional Loader/Include config reconciliation**: Loader imports a changed entry name before disposal, awaits lifecycle settlement, and restores the previous plugin or config when candidate application fails. Loader settlement rechecks service-gated fibers after current tasks drain, rejects failures, and leaves fibers with absent dependencies pending. Group updates start candidates concurrently, await every outcome, undo changes and additions on failure, await removal, preserve programmatic option identity, and persist direct or tree-level mutations only after success. Include reads and validates detached candidate content, applies patches to a clone, reconciles the tree, and only then commits its cached content/data; direct refresh failures propagate for the caller to contain. A non-array parse is invalid, patches re-apply on every file or Include-config update, an omitted patch list clears the overlay, and initial content falls back to `initial` only on `ENOENT`. Covered by `packages/ui/app-boot/tests/config-reload.spec.ts` and `packages/host/webserver/tests/webserver.spec.ts`.
9. **`hmr/src/index.ts` exact config watching**: `registerConfig()` watches one absolute config path outside module roots, including a path under missing parents, serializes and coalesces refreshes, and returns an async disposer that closes the watcher and drains active work. Refresh failures are normalized to `Error`, logged, and broadcast through the parallel `hmr/config-update-failed` event; observer failures are contained. Config-file changes discovered by the ordinary HMR watcher use the same serialized path. Covered by `packages/ui/app-boot/tests/hmr-config.spec.ts`.
9. **`hmr/src/index.ts` exact config watching**: `registerConfig()` watches one absolute config path outside module roots, including a path under missing parents, serializes and coalesces refreshes, and returns an async disposer that closes the watcher and drains active work. Module watches realpath their existing base directory; exact config watches realpath the deepest existing watch ancestor and restore the missing suffix. Those native paths prevent Windows short-name aliases from colliding with long-form libuv event paths while callbacks keep the requested filename. Refresh failures are normalized to `Error`, logged, and broadcast through the parallel `hmr/config-update-failed` event; observer failures are contained. Config-file changes discovered by the ordinary HMR watcher use the same serialized path. Covered by `packages/ui/app-boot/tests/hmr-config.spec.ts`.
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 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`.
+6
View File
@@ -7,6 +7,12 @@ module caches, and reloads only the plugin entries that depend on changed
application files. Changes to framework-level dependencies fall back to
`loader.exit()`, letting the host process restart.
Module watches canonicalize their existing base directory before opening
Chokidar. Exact config watches likewise canonicalize the deepest existing
ancestor, then restore any missing suffix. Callbacks and diagnostics retain the
requested absolute filename, while the native backend receives one filesystem
spelling even when Windows supplied an 8.3 alias.
## Requirements
- `@cordisjs/plugin-loader`
+20 -11
View File
@@ -4,7 +4,7 @@ import { ModuleLoader, type ModuleJob, type ResolveResult } from '@cordisjs/plug
import type { Include } from '@cordisjs/plugin-include'
import { FSWatcher, watch, type ChokidarOptions } from 'chokidar'
import { dirname, relative, resolve } from 'node:path'
import { stat } from 'node:fs/promises'
import { realpath, stat } from 'node:fs/promises'
import { handleError } from './error.ts'
import type {} from '@cordisjs/plugin-timer'
import { fileURLToPath, pathToFileURL } from 'node:url'
@@ -61,13 +61,18 @@ interface ConfigRegistration {
watcher: FSWatcher
}
async function findWatchRoot(filename: string): Promise<{ root: string; depth: number }> {
async function findWatchRoot(filename: string): Promise<{ filename: string; root: string; depth: number }> {
let root = dirname(filename)
let depth = 0
while (true) {
try {
if (!(await stat(root)).isDirectory()) throw new Error(`config watch parent is not a directory: ${root}`)
return { root, depth }
const canonicalRoot = await realpath(root)
return {
filename: resolve(canonicalRoot, relative(root, filename)),
root: canonicalRoot,
depth,
}
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
const parent = dirname(root)
@@ -129,9 +134,11 @@ class Hmr extends Service {
async registerConfig(filename: string, refresh: () => Promise<void> | void): Promise<() => Promise<void>> {
if (!this.watcher) throw new Error('HMR is not active')
filename = resolve(this.baseDir, filename)
if (this.configs.has(filename)) throw new Error(`config path already registered: ${filename}`)
const target = await findWatchRoot(filename)
const watchFilename = target.filename
if (this.configs.has(watchFilename)) throw new Error(`config path already registered: ${filename}`)
const { root, depth } = await findWatchRoot(filename)
const { root, depth } = target
const watcher = watch(root, {
...this.config,
cwd: undefined,
@@ -140,9 +147,10 @@ class Hmr extends Service {
ignoreInitial: false,
})
const registration = { watcher }
this.configs.set(filename, registration)
this.configs.set(watchFilename, registration)
const onChange = (path: string) => {
if (resolve(path) !== filename) return
const observed = resolve(path)
if (observed !== filename && observed !== watchFilename) return
this.refreshConfig(registration, filename, refresh)
}
watcher.on('add', onChange)
@@ -167,12 +175,12 @@ class Hmr extends Service {
try {
await ready.promise
return this.ctx.effect(() => async () => {
if (this.configs.get(filename) === registration) this.configs.delete(filename)
if (this.configs.get(watchFilename) === registration) this.configs.delete(watchFilename)
await watcher.close()
await this.configRefreshes.get(registration)?.running
}, 'hmr.registerConfig()')
} catch (error) {
this.configs.delete(filename)
this.configs.delete(watchFilename)
await watcher.close()
throw error
}
@@ -205,10 +213,11 @@ class Hmr extends Service {
}
const match = picomatch(ignored)
const watchBaseDir = await realpath(this.baseDir)
this.watcher = watch(root, {
...this.config,
cwd: this.baseDir,
ignored: path => match(relative(this.baseDir, path)),
cwd: watchBaseDir,
ignored: path => match(relative(watchBaseDir, 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