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:
Turtle
2026-08-03 14:14:20 +08:00
parent b37c22bd0a
commit ad4aeacd19
14 changed files with 156 additions and 28 deletions

View File

@@ -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