mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
fix: release canceled working-directory waiter references (#21144)
* fix: release canceled working-directory waiter references * test: normalize working-directory proof patch --------- Co-authored-by: m4air <m4air@Mac.localdomain>
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
# Canceled cwd validation waiter lifetime
|
||||
|
||||
Canceled working-directory checks retained their AbortSignals while the shared native filesystem check remained pending. The change releases those caller references immediately while preserving the underlying native operation, raw-promise identity and callback ordering.
|
||||
|
||||
**A small promise reaction and empty holder still remain per canceled wait until native settlement.** JavaScript promise reactions cannot be removed. This correction releases the signal, listener and caller resolvers; it does not establish a total bound on waiting metadata or explain an incident's memory magnitude.
|
||||
|
||||
## Ownership and callers
|
||||
|
||||
`src/main/providers/working-directory-validation.ts` keeps one pending validation per exact cwd. The raw `fs.stat` cannot be aborted, so the map entry and any UNC semaphore slot must survive caller cancellation until real settlement. Retiring them early would permit duplicate native work on the same stalled path.
|
||||
|
||||
Previously each caller registered a `finally` callback that captured its signal. The fix keeps each caller's raw promise reaction in its original position, but that reaction now references a small holder. Abort or settlement removes the abort listener and clears the holder. A separate waiter factory prevents the first signal from sharing the map's cleanup closure. The redundant per-call rejection observer is removed; the existing map-level `then(forget, forget)` still handles native failure when every caller has left.
|
||||
|
||||
The sole production importer is `pty-subprocess/spawn-preflight.ts:127–136`, through `local-pty-utils.ts`. `daemon-terminal-admission.ts` supplies a preparation signal, and `pty-subprocess.ts` forwards it to preflight. Ordinary daemon requests use a 30-second client timeout and a 5-second cancellation guard. A caller can therefore finish while the native filesystem operation remains pending across later requests. Those request timers do not bound the raw stat duration.
|
||||
|
||||
No-signal callers still receive the exact original promise. Native map deletion, UNC lane ownership, WSL checks, creation reservations, shutdown and process authority are unchanged. The change stays on the execution host and applies to folder workspaces and git worktrees without a wire change.
|
||||
|
||||
## Why each raw reaction remains
|
||||
|
||||
Existing wait utilities were checked. A shared settlement observer changes this API's callback order: a raw-promise observer registered before a signal waiter can abort it before its raw result arrives. Moving every waiter behind an earlier shared observer would fulfill that waiter instead. The per-call holder preserves that order and synchronous cancellation. Six permanent regressions cover an external aborting observer before, between and after signal waiters, for native success and failure.
|
||||
|
||||
## Before/after evidence
|
||||
|
||||
The standalone proof bundles the actual validation module, UNC path parser and semaphore. Its native async stat is a deferred fixture; WSL subprocess operations throw if unexpectedly reached. It performs no actual cwd probe, remote filesystem access, native subprocess launch or app launch. The fixture contains 32 small canceled callers per outcome; no large payload is attached.
|
||||
|
||||
| Before raw native settlement | Original Node 26.6.0 | Original Electron 43.7.0 / Node 24.21.0 | Fixed, both |
|
||||
| ------------------------------- | -------------------- | --------------------------------------- | ----------- |
|
||||
| Signals reachable | 32 | 32 | 0 |
|
||||
| Cancellation errors reachable | 0 | 32 | 0 |
|
||||
| Caller option objects reachable | 0 | 0 | 0 |
|
||||
| Native stat calls | 1 | 1 | 1 |
|
||||
|
||||
All measured caller objects collect after native settlement in both versions. Both native success and failure have the same lifetime result. Other controls pass on both runtimes:
|
||||
|
||||
- Later live waiters receive the original operation's success or actionable error, and their listeners are removed.
|
||||
- An already-aborted first caller still leaves the raw operation owned; no-signal callers share the same raw promise.
|
||||
- Three canceled callers on one UNC host leave two native slots occupied. The third native operation starts only after one real completion.
|
||||
- Forty-eight actual-module settlement/abort schedules and six raw-observer ordering cases match the original.
|
||||
- Native rejection after all callers cancel produces no unhandled rejection.
|
||||
- Synthetic CRLF source and patch reads produce identical reversed/fixed source and hashes without product writes.
|
||||
|
||||
`sources.cjs` reverses `fix.patch` in memory and checks exact baseline and fixed SHA-256 values. Source hashes use canonical LF; reports include effective dependency and bundle hashes. No git history, ignored notes or copied production implementation is needed to rerun the proof. Each run has a 20-second deadline; the commands below set a 192 MiB heap limit.
|
||||
|
||||
```sh
|
||||
ORCA_BACKGROUND_LAUNCH=1 node --expose-gc --max-old-space-size=192 docs/audits/working-directory-wait-retention/reproduce.cjs
|
||||
```
|
||||
|
||||
For Electron, run its installed executable with the same flags and script path, setting `ELECTRON_RUN_AS_NODE=1` and `ORCA_BACKGROUND_LAUNCH=1`. It runs in Node mode and creates no windows.
|
||||
|
||||
## Source compatibility and validation
|
||||
|
||||
The audited baseline module is byte-identical to main commit `291b4ddd6f1c1af480169885e0fda7f9c78ff053` and release `v1.4.198` (`e0826956fcfc532f5a1e55b5e081f2e57e553c43`). All nine recorded caller/dependency sources also match that main commit. This one-product-file change does not depend on the shared waiter helper or its auth-wait changes. `source-versions.json` records the exact comparisons; historical source equality is not a historical packaged-runtime reproduction.
|
||||
|
||||
The fixed three-file regression run passed 31 tests. Reversing only this fix gives one expected first-caller retention failure and 30 passing controls, including the six raw-observer cases:
|
||||
|
||||
```sh
|
||||
ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config docs/audits/working-directory-wait-retention/before.config.mjs src/main/providers/working-directory-validation-retention.test.ts src/main/providers/working-directory-validation.test.ts src/main/daemon/pty-subprocess-cwd-cancel-identity.test.ts
|
||||
```
|
||||
|
||||
`validation.json` records verification results. The measured retention requires a still-pending native operation; no affected-host capture, byte slope or attribution to #19831 is claimed.
|
||||
@@ -0,0 +1,24 @@
|
||||
import { resolve } from 'node:path'
|
||||
import { createRequire } from 'node:module'
|
||||
import { defineConfig, mergeConfig } from 'vitest/config'
|
||||
import baseConfig from '../../../config/vitest.config.ts'
|
||||
|
||||
const { loadSources } = createRequire(import.meta.url)(
|
||||
resolve('docs/audits/working-directory-wait-retention/sources.cjs')
|
||||
)
|
||||
const { before } = loadSources()
|
||||
export default mergeConfig(
|
||||
baseConfig,
|
||||
defineConfig({
|
||||
plugins: [
|
||||
{
|
||||
name: 'cwd-wait-before-fix',
|
||||
enforce: 'pre',
|
||||
transform(_code, id) {
|
||||
const source = before.get(resolve(id.split('?')[0]))
|
||||
return source === undefined ? undefined : { code: source, map: null }
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,68 @@
|
||||
diff --git a/src/main/providers/working-directory-validation.ts b/src/main/providers/working-directory-validation.ts
|
||||
index 688cc85244..cf098cad2c 100644
|
||||
--- a/src/main/providers/working-directory-validation.ts
|
||||
+++ b/src/main/providers/working-directory-validation.ts
|
||||
@@ -165,12 +165 @@ export function validateWorkingDirectoryAsync(
|
||||
- const shared = validation
|
||||
- // The shared probe outlives this caller; keep it from surfacing as unhandled.
|
||||
- void shared.catch(() => {})
|
||||
- return new Promise<void>((resolve, reject) => {
|
||||
- const onAbort = (): void => reject(new WorkingDirectoryValidationAbortedError(cwd))
|
||||
- if (signal.aborted) {
|
||||
- onAbort()
|
||||
- return
|
||||
- }
|
||||
- signal.addEventListener('abort', onAbort, { once: true })
|
||||
- shared.then(resolve, reject).finally(() => signal.removeEventListener('abort', onAbort))
|
||||
- })
|
||||
+ return waitForWorkingDirectoryValidation(validation, cwd, signal)
|
||||
@@ -204,0 +194,49 @@ async function probeWorkingDirectory(cwd: string): Promise<void> {
|
||||
+
|
||||
+type WorkingDirectoryWaiterHolder = {
|
||||
+ waiter: {
|
||||
+ signal: AbortSignal
|
||||
+ onAbort: () => void
|
||||
+ resolve: () => void
|
||||
+ reject: (error: unknown) => void
|
||||
+ } | null
|
||||
+}
|
||||
+
|
||||
+function takeWorkingDirectoryWaiter(
|
||||
+ holder: WorkingDirectoryWaiterHolder
|
||||
+): WorkingDirectoryWaiterHolder['waiter'] {
|
||||
+ const waiter = holder.waiter
|
||||
+ holder.waiter = null
|
||||
+ waiter?.signal.removeEventListener('abort', waiter.onAbort)
|
||||
+ return waiter
|
||||
+}
|
||||
+
|
||||
+// Keep reaction order while an abandoned caller's signal and resolver become collectible.
|
||||
+function observeWorkingDirectoryValidation(
|
||||
+ promise: Promise<void>,
|
||||
+ holder: WorkingDirectoryWaiterHolder
|
||||
+): void {
|
||||
+ void promise.then(
|
||||
+ () => takeWorkingDirectoryWaiter(holder)?.resolve(),
|
||||
+ (error: unknown) => takeWorkingDirectoryWaiter(holder)?.reject(error)
|
||||
+ )
|
||||
+}
|
||||
+
|
||||
+function waitForWorkingDirectoryValidation(
|
||||
+ shared: Promise<void>,
|
||||
+ cwd: string,
|
||||
+ signal: AbortSignal
|
||||
+): Promise<void> {
|
||||
+ return new Promise<void>((resolve, reject) => {
|
||||
+ const holder: WorkingDirectoryWaiterHolder = { waiter: null }
|
||||
+ const onAbort = (): void => {
|
||||
+ takeWorkingDirectoryWaiter(holder)?.reject(new WorkingDirectoryValidationAbortedError(cwd))
|
||||
+ }
|
||||
+ holder.waiter = { signal, onAbort, resolve, reject }
|
||||
+ if (signal.aborted) {
|
||||
+ onAbort()
|
||||
+ return
|
||||
+ }
|
||||
+ signal.addEventListener('abort', onAbort, { once: true })
|
||||
+ observeWorkingDirectoryValidation(shared, holder)
|
||||
+ })
|
||||
+}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,107 @@
|
||||
const assert = require('node:assert/strict')
|
||||
const { readFileSync, writeFileSync } = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const { load, loadSources, canonicalLf } = require('./sources.cjs')
|
||||
const {
|
||||
fixtureKey,
|
||||
lifetime,
|
||||
laneOwnership,
|
||||
ordering,
|
||||
observerOrdering,
|
||||
alreadyAborted,
|
||||
canceledNativeRejection
|
||||
} = require('./scenario.cjs')
|
||||
|
||||
assert.equal(process.env.ORCA_BACKGROUND_LAUNCH, '1')
|
||||
assert.equal(typeof global.gc, 'function')
|
||||
process.env.ORCA_APP_VERSION = 'synthetic-cwd-validation-audit'
|
||||
function checkCrlfLoader() {
|
||||
const baseline = loadSources()
|
||||
let syntheticCrlfReads = 0
|
||||
const crlf = loadSources({
|
||||
readText(file) {
|
||||
syntheticCrlfReads++
|
||||
return canonicalLf(readFileSync(file, 'utf8')).replace(/\n/g, '\r\n')
|
||||
}
|
||||
})
|
||||
assert.equal(syntheticCrlfReads, 2)
|
||||
assert.deepEqual(crlf.before, baseline.before)
|
||||
assert.deepEqual(crlf.after, baseline.after)
|
||||
assert.deepEqual(crlf.hashes, baseline.hashes)
|
||||
return { syntheticCrlfReads, identicalSourcesAndHashes: true }
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const timer = setTimeout(() => {
|
||||
process.stderr.write('deadline\n')
|
||||
process.exit(2)
|
||||
}, 20_000)
|
||||
const unhandled = []
|
||||
const recordUnhandled = (error) => unhandled.push(error)
|
||||
process.on('unhandledRejection', recordUnhandled)
|
||||
const crlfLoaderControl = checkCrlfLoader()
|
||||
const loaded = { before: await load(false, fixtureKey), after: await load(true, fixtureKey) }
|
||||
const reports = {}
|
||||
for (const [mode, validation] of Object.entries(loaded)) {
|
||||
reports[mode] = {
|
||||
fulfilled: await lifetime(validation, mode === 'after', false),
|
||||
rejected: await lifetime(validation, mode === 'after', true),
|
||||
laneOwnership: await laneOwnership(validation),
|
||||
alreadyAborted: await alreadyAborted(validation),
|
||||
lateNativeRejection: await canceledNativeRejection(validation)
|
||||
}
|
||||
}
|
||||
const matrix = []
|
||||
for (const reject of [false, true]) {
|
||||
for (const startedBefore of [false, true]) {
|
||||
for (const ticks of [0, 1, 2, 3, 4, 8]) {
|
||||
for (const abortFirst of [false, true]) {
|
||||
const args = [reject, startedBefore, ticks, abortFirst]
|
||||
const before = await ordering(loaded.before, ...args)
|
||||
const after = await ordering(loaded.after, ...args)
|
||||
assert.deepEqual(after, before)
|
||||
matrix.push({ reject, startedBefore, ticks, abortFirst, before, after })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const observers = []
|
||||
for (const position of ['before', 'between', 'after']) {
|
||||
for (const reject of [false, true]) {
|
||||
const before = await observerOrdering(loaded.before, position, reject)
|
||||
const after = await observerOrdering(loaded.after, position, reject)
|
||||
assert.deepEqual(after, before)
|
||||
observers.push({ position, reject, before, after })
|
||||
}
|
||||
}
|
||||
await new Promise(setImmediate)
|
||||
assert.deepEqual(unhandled, [])
|
||||
process.off('unhandledRejection', recordUnhandled)
|
||||
clearTimeout(timer)
|
||||
delete globalThis[fixtureKey]
|
||||
const report = {
|
||||
runtime: process.versions,
|
||||
sourceHashLineEndings: 'canonical LF',
|
||||
crlfLoaderControl,
|
||||
unhandledRejections: unhandled.length,
|
||||
reports,
|
||||
matrix,
|
||||
observers,
|
||||
versions: Object.fromEntries(
|
||||
Object.entries(loaded).map(([mode, value]) => [mode, value.versions])
|
||||
),
|
||||
scope:
|
||||
'Actual cwd validation and semaphore source. Native stat replaced by one bounded deferred fixture; no filesystem probe, WSL process or app. 32 canceled small signals per case. Native ownership retained until actual fixture settlement, including shared UNC lane. Small per-call reaction/empty-holder metadata still lives until native settlement. No synthetic large payload or incident RSS claim.'
|
||||
}
|
||||
writeFileSync(
|
||||
path.join(__dirname, process.versions.electron ? 'electron-results.json' : 'node-results.json'),
|
||||
`${JSON.stringify(report, null, 2)}\n`
|
||||
)
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({ runtime: process.versions.node, reports, orderingCases: matrix.length, observerCases: observers.length, unhandledRejections: unhandled.length }, null, 2)}\n`
|
||||
)
|
||||
}
|
||||
main().catch((error) => {
|
||||
process.stderr.write(`${error.stack}\n`)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -0,0 +1,235 @@
|
||||
const assert = require('node:assert/strict')
|
||||
const { getEventListeners } = require('node:events')
|
||||
|
||||
const fixtureKey = '__orcaWorkingDirectoryWaitFixture'
|
||||
const validDirectory = { isDirectory: () => true }
|
||||
async function collect() {
|
||||
for (let round = 0; round < 6; round++) {
|
||||
await new Promise(setImmediate)
|
||||
global.gc()
|
||||
}
|
||||
}
|
||||
const tick = async (count) => {
|
||||
for (let i = 0; i < count; i++) {
|
||||
await Promise.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
async function canceledWait(validation, cwd) {
|
||||
const controller = new AbortController()
|
||||
const options = { signal: controller.signal }
|
||||
const refs = { signal: new WeakRef(controller.signal), options: new WeakRef(options) }
|
||||
const waiting = validation.validateWorkingDirectoryAsync(cwd, options)
|
||||
controller.abort()
|
||||
await assert.rejects(waiting, (error) => {
|
||||
refs.error = new WeakRef(error)
|
||||
return error instanceof validation.WorkingDirectoryValidationAbortedError
|
||||
})
|
||||
assert.equal(getEventListeners(controller.signal, 'abort').length, 0)
|
||||
return refs
|
||||
}
|
||||
const counts = (refs) =>
|
||||
Object.fromEntries(
|
||||
['signal', 'options', 'error'].map((key) => [
|
||||
key,
|
||||
refs.filter((ref) => ref[key].deref()).length
|
||||
])
|
||||
)
|
||||
|
||||
async function lifetime(validation, fixed, reject) {
|
||||
const gate = Promise.withResolvers()
|
||||
let statCalls = 0
|
||||
globalThis[fixtureKey] = {
|
||||
stat() {
|
||||
statCalls++
|
||||
return gate.promise
|
||||
}
|
||||
}
|
||||
const cwd = `synthetic-validation-${reject}`
|
||||
const refs = []
|
||||
for (let index = 0; index < 32; index++) {
|
||||
refs.push(await canceledWait(validation, cwd))
|
||||
}
|
||||
await collect()
|
||||
const beforeSettlement = counts(refs)
|
||||
assert.equal(statCalls, 1)
|
||||
assert.equal(beforeSettlement.options, 0)
|
||||
assert.equal(beforeSettlement.signal, fixed ? 0 : 32)
|
||||
if (fixed) {
|
||||
assert.equal(beforeSettlement.error, 0)
|
||||
}
|
||||
const controller = new AbortController()
|
||||
const late = validation.validateWorkingDirectoryAsync(cwd, { signal: controller.signal }).then(
|
||||
() => ({ status: 'fulfilled' }),
|
||||
(error) => ({ status: 'rejected', message: error.message })
|
||||
)
|
||||
assert.equal(statCalls, 1)
|
||||
assert.equal(getEventListeners(controller.signal, 'abort').length, 1)
|
||||
if (reject) {
|
||||
gate.reject(new Error('synthetic native failure'))
|
||||
} else {
|
||||
gate.resolve(validDirectory)
|
||||
}
|
||||
const lateResult = await late
|
||||
assert.equal(lateResult.status, reject ? 'rejected' : 'fulfilled')
|
||||
await collect()
|
||||
const afterSettlement = counts(refs)
|
||||
assert.deepEqual(afterSettlement, { signal: 0, options: 0, error: 0 })
|
||||
assert.equal(getEventListeners(controller.signal, 'abort').length, 0)
|
||||
globalThis[fixtureKey] = {
|
||||
stat() {
|
||||
statCalls++
|
||||
return Promise.resolve(validDirectory)
|
||||
}
|
||||
}
|
||||
await validation.validateWorkingDirectoryAsync(cwd)
|
||||
assert.equal(statCalls, 2)
|
||||
return {
|
||||
beforeSettlement,
|
||||
afterSettlement,
|
||||
nativeCallsBeforeSettlement: 1,
|
||||
nativeCallsAfterFreshValidation: statCalls,
|
||||
lateResult
|
||||
}
|
||||
}
|
||||
|
||||
async function laneOwnership(validation) {
|
||||
const gates = [Promise.withResolvers(), Promise.withResolvers(), Promise.withResolvers()]
|
||||
const started = []
|
||||
globalThis[fixtureKey] = {
|
||||
stat(cwd) {
|
||||
started.push(cwd)
|
||||
return gates[started.length - 1].promise
|
||||
}
|
||||
}
|
||||
const paths = Array.from({ length: 3 }, (_, index) => `\\\\synthetic-host\\dir-${index}`)
|
||||
for (const cwd of paths) {
|
||||
await canceledWait(validation, cwd)
|
||||
}
|
||||
await tick(8)
|
||||
assert.equal(started.length, 2)
|
||||
gates[0].resolve(validDirectory)
|
||||
await new Promise(setImmediate)
|
||||
assert.equal(started.length, 3)
|
||||
gates[1].resolve(validDirectory)
|
||||
gates[2].resolve(validDirectory)
|
||||
await new Promise(setImmediate)
|
||||
return {
|
||||
canceledWaits: 3,
|
||||
nativeCallsWhileBothSlotsOwned: 2,
|
||||
nativeCallsAfterOneRawCompletion: 3
|
||||
}
|
||||
}
|
||||
|
||||
async function ordering(validation, reject, startedBefore, ticks, abortFirst) {
|
||||
const gate = Promise.withResolvers()
|
||||
let calls = 0
|
||||
globalThis[fixtureKey] = {
|
||||
stat() {
|
||||
calls++
|
||||
return calls === 1 ? gate.promise : Promise.resolve(validDirectory)
|
||||
}
|
||||
}
|
||||
const cwd = `matrix-${reject}-${startedBefore}-${ticks}-${abortFirst}`
|
||||
const anchor = validation.validateWorkingDirectoryAsync(cwd).catch(() => {})
|
||||
const controller = new AbortController()
|
||||
const start = () =>
|
||||
validation.validateWorkingDirectoryAsync(cwd, { signal: controller.signal }).then(
|
||||
() => ['fulfilled'],
|
||||
(error) => ['rejected', error.name, error.message]
|
||||
)
|
||||
let waiting = startedBefore ? start() : null
|
||||
const settle = () =>
|
||||
reject ? gate.reject(new Error('raw failure')) : gate.resolve(validDirectory)
|
||||
if (abortFirst) {
|
||||
controller.abort()
|
||||
} else {
|
||||
settle()
|
||||
}
|
||||
await tick(ticks)
|
||||
waiting ??= start()
|
||||
if (abortFirst) {
|
||||
settle()
|
||||
} else {
|
||||
controller.abort()
|
||||
}
|
||||
const outcome = await waiting
|
||||
await anchor
|
||||
await new Promise(setImmediate)
|
||||
assert.equal(getEventListeners(controller.signal, 'abort').length, 0)
|
||||
return { outcome, calls }
|
||||
}
|
||||
|
||||
async function observerOrdering(validation, position, reject) {
|
||||
const gate = Promise.withResolvers()
|
||||
globalThis[fixtureKey] = { stat: () => gate.promise }
|
||||
const cwd = `observer-${position}-${reject}`
|
||||
const raw = validation.validateWorkingDirectoryAsync(cwd)
|
||||
const controller = new AbortController()
|
||||
const start = () =>
|
||||
validation.validateWorkingDirectoryAsync(cwd, { signal: controller.signal }).then(
|
||||
() => ['fulfilled'],
|
||||
(error) => ['rejected', error.name]
|
||||
)
|
||||
const waiting = []
|
||||
if (position !== 'before') {
|
||||
waiting.push(start())
|
||||
}
|
||||
const abortObserver = raw.then(
|
||||
() => controller.abort(),
|
||||
() => controller.abort()
|
||||
)
|
||||
if (position !== 'after') {
|
||||
waiting.push(start())
|
||||
}
|
||||
if (reject) {
|
||||
gate.reject(new Error('raw failure'))
|
||||
} else {
|
||||
gate.resolve(validDirectory)
|
||||
}
|
||||
const outcomes = await Promise.all(waiting)
|
||||
await abortObserver
|
||||
return outcomes
|
||||
}
|
||||
|
||||
async function alreadyAborted(validation) {
|
||||
const gate = Promise.withResolvers()
|
||||
let nativeCalls = 0
|
||||
globalThis[fixtureKey] = {
|
||||
stat() {
|
||||
nativeCalls++
|
||||
return gate.promise
|
||||
}
|
||||
}
|
||||
const signal = AbortSignal.abort()
|
||||
await assert.rejects(
|
||||
validation.validateWorkingDirectoryAsync('pre-aborted', { signal }),
|
||||
(error) => error instanceof validation.WorkingDirectoryValidationAbortedError
|
||||
)
|
||||
assert.equal(getEventListeners(signal, 'abort').length, 0)
|
||||
const raw = validation.validateWorkingDirectoryAsync('pre-aborted')
|
||||
assert.equal(validation.validateWorkingDirectoryAsync('pre-aborted'), raw)
|
||||
assert.equal(nativeCalls, 1)
|
||||
gate.resolve(validDirectory)
|
||||
await raw
|
||||
return { nativeCalls, noSignalPromiseIdentityPreserved: true }
|
||||
}
|
||||
|
||||
async function canceledNativeRejection(validation) {
|
||||
const gate = Promise.withResolvers()
|
||||
globalThis[fixtureKey] = { stat: () => gate.promise }
|
||||
await canceledWait(validation, 'late-native-rejection')
|
||||
gate.reject(new Error('Native failure after all callers canceled'))
|
||||
await new Promise(setImmediate)
|
||||
return { nativeRejectedAfterCallerCanceled: true }
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
fixtureKey,
|
||||
lifetime,
|
||||
laneOwnership,
|
||||
ordering,
|
||||
observerOrdering,
|
||||
alreadyAborted,
|
||||
canceledNativeRejection
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
{
|
||||
"baselineHashes": {
|
||||
"src/main/providers/working-directory-validation.ts": "197bcb7ab254d2bca8cdcc7d7f0214dedca825bbb0dc4f0d2dcbb41df4cec043"
|
||||
},
|
||||
"fixedHashes": {
|
||||
"src/main/providers/working-directory-validation.ts": "0ef75b5d5242ad7e5423bde2cb7fe633a8f437fc545f22ca04612782fc2de212"
|
||||
},
|
||||
"namedRefs": [
|
||||
{
|
||||
"ref": "HEAD",
|
||||
"revision": "96970f9b6efe915f9578b765c93f3d06880ccc2d",
|
||||
"sha256": "197bcb7ab254d2bca8cdcc7d7f0214dedca825bbb0dc4f0d2dcbb41df4cec043",
|
||||
"matchesAuditBaseline": true,
|
||||
"patchAppliesWithIdenticalFixedHash": true
|
||||
},
|
||||
{
|
||||
"ref": "291b4ddd6f1c1af480169885e0fda7f9c78ff053",
|
||||
"revision": "291b4ddd6f1c1af480169885e0fda7f9c78ff053",
|
||||
"sha256": "197bcb7ab254d2bca8cdcc7d7f0214dedca825bbb0dc4f0d2dcbb41df4cec043",
|
||||
"matchesAuditBaseline": true,
|
||||
"patchAppliesWithIdenticalFixedHash": true
|
||||
},
|
||||
{
|
||||
"ref": "v1.4.198",
|
||||
"revision": "e0826956fcfc532f5a1e55b5e081f2e57e553c43",
|
||||
"sha256": "197bcb7ab254d2bca8cdcc7d7f0214dedca825bbb0dc4f0d2dcbb41df4cec043",
|
||||
"matchesAuditBaseline": true,
|
||||
"patchAppliesWithIdenticalFixedHash": true
|
||||
}
|
||||
],
|
||||
"sourceHashLineEndings": "canonical LF",
|
||||
"historicalRuntimeReproduced": false,
|
||||
"sharedWaiterDependency": false,
|
||||
"callerProvenance": [
|
||||
{
|
||||
"path": "src/main/daemon/pty-subprocess/spawn-preflight.ts",
|
||||
"sha256": "54b19c44a2f7beabb7a1dcb817efc26be1c9b13462a410e82c2da88f5550ceb2",
|
||||
"main291bSha256": "54b19c44a2f7beabb7a1dcb817efc26be1c9b13462a410e82c2da88f5550ceb2"
|
||||
},
|
||||
{
|
||||
"path": "src/main/providers/local-pty-utils.ts",
|
||||
"sha256": "d655643b42503ccfdab6436273557581b447f4b9f452dfa7649a959705744f25",
|
||||
"main291bSha256": "d655643b42503ccfdab6436273557581b447f4b9f452dfa7649a959705744f25"
|
||||
},
|
||||
{
|
||||
"path": "src/main/daemon/pty-subprocess.ts",
|
||||
"sha256": "9f32c99c79fd3a23fd61d572146f81c3680fb5cbc13337e4fb3179856a0d7dc1",
|
||||
"main291bSha256": "9f32c99c79fd3a23fd61d572146f81c3680fb5cbc13337e4fb3179856a0d7dc1"
|
||||
},
|
||||
{
|
||||
"path": "src/main/daemon/daemon-terminal-admission.ts",
|
||||
"sha256": "14d1d8bbf4b662c98db123719741daffc3d0fe911d3a6a08d3516be666068251",
|
||||
"main291bSha256": "14d1d8bbf4b662c98db123719741daffc3d0fe911d3a6a08d3516be666068251"
|
||||
},
|
||||
{
|
||||
"path": "src/main/daemon/daemon-pty-spawn-preparations.ts",
|
||||
"sha256": "77c94466578a5066af069e45eb5e87a64b73b88580a13b2172b2ea846b434f4e",
|
||||
"main291bSha256": "77c94466578a5066af069e45eb5e87a64b73b88580a13b2172b2ea846b434f4e"
|
||||
},
|
||||
{
|
||||
"path": "src/main/daemon/daemon-client-rpc-request.ts",
|
||||
"sha256": "c846c4e7cb1a5eaa3266e3f3779dbd62c7f60a0c783c55d2af80c42d8468db7b",
|
||||
"main291bSha256": "c846c4e7cb1a5eaa3266e3f3779dbd62c7f60a0c783c55d2af80c42d8468db7b"
|
||||
},
|
||||
{
|
||||
"path": "src/main/daemon/client.ts",
|
||||
"sha256": "e7698849f62c51bd36bf6789423dcd3f9194d979fc143dd267f0c1b1de1ddf14",
|
||||
"main291bSha256": "e7698849f62c51bd36bf6789423dcd3f9194d979fc143dd267f0c1b1de1ddf14"
|
||||
},
|
||||
{
|
||||
"path": "src/shared/priority-semaphore.ts",
|
||||
"sha256": "509693d0132f2365bf1702bcbae7efc4bb0eceeb6ea235456f56a5f784da85a5",
|
||||
"main291bSha256": "509693d0132f2365bf1702bcbae7efc4bb0eceeb6ea235456f56a5f784da85a5"
|
||||
},
|
||||
{
|
||||
"path": "src/shared/wsl-paths.ts",
|
||||
"sha256": "1d9dcf5a1ff6693c02ff60a6b074eb2ebb7e83bda12aceba7e0b208d410b6d1a",
|
||||
"main291bSha256": "1d9dcf5a1ff6693c02ff60a6b074eb2ebb7e83bda12aceba7e0b208d410b6d1a"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
const assert = require('node:assert/strict')
|
||||
const { readFileSync } = require('node:fs')
|
||||
const { createHash } = require('node:crypto')
|
||||
const path = require('node:path')
|
||||
const Module = require('node:module')
|
||||
const esbuild = require('esbuild')
|
||||
const root = path.resolve(__dirname, '../../..')
|
||||
const read = (file) => readFileSync(file, 'utf8').replace(/\r\n/g, '\n')
|
||||
const sha = (value) => createHash('sha256').update(value).digest('hex')
|
||||
const sourcePath = 'src/main/providers/working-directory-validation.ts'
|
||||
const { applyPatch, parsePatch, reversePatch } = require('diff')
|
||||
const { resolve } = path
|
||||
const canonicalLf = (text) => text.replace(/\r\n/g, '\n')
|
||||
|
||||
function loadSources({ readText = (file) => readFileSync(file, 'utf8') } = {}) {
|
||||
const root = resolve(__dirname, '../../..')
|
||||
const expected = JSON.parse(readFileSync(resolve(__dirname, 'source-versions.json'), 'utf8'))
|
||||
const parsed = parsePatch(canonicalLf(readText(resolve(__dirname, 'fix.patch'))))
|
||||
const before = new Map()
|
||||
const after = new Map()
|
||||
const hashes = {}
|
||||
assert.equal(parsed.length, 1)
|
||||
for (const patch of parsed) {
|
||||
const path = patch.newFileName.replace(/^b\//, '')
|
||||
assert.ok(Object.hasOwn(expected.baselineHashes, path), `Unexpected patch path: ${path}`)
|
||||
const absolute = resolve(root, path)
|
||||
const current = canonicalLf(readText(absolute))
|
||||
const baseline = applyPatch(current, reversePatch(patch))
|
||||
assert.notEqual(baseline, false, `Source changed; review fix.patch: ${path}`)
|
||||
const hash = (source) => createHash('sha256').update(source).digest('hex')
|
||||
assert.equal(hash(baseline), expected.baselineHashes[path], `Baseline drift: ${path}`)
|
||||
assert.equal(hash(current), expected.fixedHashes[path], `Fixed source drift: ${path}`)
|
||||
before.set(absolute, baseline)
|
||||
after.set(absolute, current)
|
||||
hashes[path] = { before: hash(baseline), after: hash(current) }
|
||||
}
|
||||
return { root, before, after, hashes }
|
||||
}
|
||||
|
||||
async function load(fixed, fixtureKey) {
|
||||
const { before, after, hashes } = loadSources()
|
||||
const original = before.get(path.join(root, sourcePath))
|
||||
const candidate = after.get(path.join(root, sourcePath))
|
||||
const build = await esbuild.build({
|
||||
entryPoints: [path.join(root, sourcePath)],
|
||||
platform: 'node',
|
||||
format: 'cjs',
|
||||
bundle: true,
|
||||
packages: 'external',
|
||||
write: false,
|
||||
metafile: true,
|
||||
plugins: [
|
||||
{
|
||||
name: 'validation-native-stat-port',
|
||||
setup(builder) {
|
||||
builder.onLoad({ filter: /working-directory-validation\.ts$/ }, (args) => {
|
||||
assert.equal(args.path, path.join(root, sourcePath))
|
||||
return { contents: fixed ? candidate : original, loader: 'ts' }
|
||||
})
|
||||
builder.onResolve({ filter: /^node:fs\/promises$/ }, () => ({
|
||||
path: 'native-stat',
|
||||
namespace: 'fixture'
|
||||
}))
|
||||
builder.onResolve({ filter: /\/wsl$/ }, () => ({
|
||||
path: 'no-wsl-process',
|
||||
namespace: 'fixture'
|
||||
}))
|
||||
builder.onLoad({ filter: /.*/, namespace: 'fixture' }, (args) => ({
|
||||
contents:
|
||||
args.path === 'native-stat'
|
||||
? `export const stat = (...args) => globalThis[${JSON.stringify(fixtureKey)}].stat(...args)`
|
||||
: "const unexpected = () => { throw new Error('No native WSL operation permitted') }; export const wslUncDirectoryExists = unexpected; export const wslUncDirectoryExistsAsync = unexpected",
|
||||
loader: 'js'
|
||||
}))
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
const filename = path.join(__dirname, 'in-memory-validation.cjs')
|
||||
const loaded = new Module(filename, module)
|
||||
loaded.filename = filename
|
||||
loaded.paths = Module._nodeModulePaths(__dirname)
|
||||
loaded._compile(build.outputFiles[0].text, filename)
|
||||
return {
|
||||
...loaded.exports,
|
||||
versions: {
|
||||
sourceHashes: hashes,
|
||||
bundleSha256: sha(build.outputFiles[0].contents),
|
||||
dependencies: Object.keys(build.metafile.inputs)
|
||||
.filter((file) => file.startsWith('src/'))
|
||||
.map((file) => ({
|
||||
path: file,
|
||||
sha256: sha(
|
||||
file === sourcePath ? (fixed ? candidate : original) : read(path.join(root, file))
|
||||
)
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
module.exports = { load, loadSources, canonicalLf }
|
||||
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"tests": {
|
||||
"command": "ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config config/vitest.config.ts src/main/providers/working-directory-validation-retention.test.ts src/main/providers/working-directory-validation.test.ts src/main/daemon/pty-subprocess-cwd-cancel-identity.test.ts",
|
||||
"passed": 31,
|
||||
"files": 3,
|
||||
"exitCode": 0,
|
||||
"newRegressionCases": 12
|
||||
},
|
||||
"baselineOverlay": {
|
||||
"command": "ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config docs/audits/working-directory-wait-retention/before.config.mjs src/main/providers/working-directory-validation-retention.test.ts src/main/providers/working-directory-validation.test.ts src/main/daemon/pty-subprocess-cwd-cancel-identity.test.ts",
|
||||
"passed": 30,
|
||||
"expectedFailed": 1,
|
||||
"failure": "releases the first caller and subsequent canceled callers while their native stat stays owned",
|
||||
"exitCode": 1
|
||||
},
|
||||
"typecheck": {
|
||||
"command": "ORCA_BACKGROUND_LAUNCH=1 pnpm tc:node",
|
||||
"exitCode": 0
|
||||
},
|
||||
"lint": {
|
||||
"files": [
|
||||
"src/main/providers/working-directory-validation.ts",
|
||||
"src/main/providers/working-directory-validation-retention.test.ts",
|
||||
"docs/audits/working-directory-wait-retention/sources.cjs",
|
||||
"docs/audits/working-directory-wait-retention/scenario.cjs",
|
||||
"docs/audits/working-directory-wait-retention/reproduce.cjs",
|
||||
"docs/audits/working-directory-wait-retention/before.config.mjs"
|
||||
],
|
||||
"ordinary": "pnpm exec oxlint --no-ignore <files>",
|
||||
"typeAware": "pnpm exec oxlint --no-ignore --type-aware --config config/oxlint-code-quality-type-aware.json <files> --deny-warnings",
|
||||
"exitCodes": [0, 0]
|
||||
},
|
||||
"changedQuality": {
|
||||
"command": "ORCA_BACKGROUND_LAUNCH=1 ORCA_CODE_QUALITY_BASE=96970f9b6efe915f9578b765c93f3d06880ccc2d pnpm run check:code-quality:changed",
|
||||
"exitCode": 0,
|
||||
"changedFiles": 2,
|
||||
"newFindings": 0
|
||||
},
|
||||
"proofs": {
|
||||
"node": "ORCA_BACKGROUND_LAUNCH=1 node --expose-gc --max-old-space-size=192 docs/audits/working-directory-wait-retention/reproduce.cjs",
|
||||
"electron": "Installed Electron executable with ELECTRON_RUN_AS_NODE=1 and ORCA_BACKGROUND_LAUNCH=1; same flags and proof path.",
|
||||
"exitCodes": [0, 0],
|
||||
"nativeSettlementOrderingCasesPerRuntime": 48,
|
||||
"externalRawObserverCasesPerRuntime": 6,
|
||||
"unhandledRejectionsPerRuntime": 0,
|
||||
"crlfLoaderControlPerRuntime": true
|
||||
},
|
||||
"format": "All product TS and artifact CJS/MJS/MD/JSON checked with oxfmt --stdin-filepath; patch excluded.",
|
||||
"gitDiffCheckExitCode": 0,
|
||||
"historicalCompatibility": "All three named baseline refs accept fix.patch and produce identical fixed SHA-256; all nine recorded provenance sources match main291b. No shared-waiter dependency.",
|
||||
"artifactNormalization": {
|
||||
"change": "Regenerated fix.patch with zero context so stored context blank lines do not appear as trailing whitespace when checked as a new artifact. Product/tests unchanged.",
|
||||
"proofs": "Both Node/Electron 54-case ordering/lifecycle runs pass with the regenerated patch; formatted result files are byte-identical to the prior capture.",
|
||||
"quality": "All six published code files explicitly scanned through five quality configurations with --no-ignore. Ordinary/type-aware/React/design scans pass; whole-file casting scan reports one unchanged assertion at product line84 present in the baseline. Root changed-lines gate since96970f9b passes all five scans across11 changed files with zero new findings.",
|
||||
"whitespace": "All 12 publication files checked as complete additions; no whitespace diagnostics."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
import { getEventListeners } from 'node:events'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { stat } = vi.hoisted(() => ({
|
||||
stat: vi.fn<() => Promise<{ isDirectory: () => boolean }>>()
|
||||
}))
|
||||
vi.mock('node:fs/promises', () => ({ stat }))
|
||||
vi.mock('../wsl', () => ({
|
||||
wslUncDirectoryExists: () => {
|
||||
throw new Error('Unexpected WSL probe')
|
||||
},
|
||||
wslUncDirectoryExistsAsync: () => {
|
||||
throw new Error('Unexpected WSL probe')
|
||||
}
|
||||
}))
|
||||
|
||||
import {
|
||||
_resetWorkingDirectoryValidationStateForTest,
|
||||
validateWorkingDirectoryAsync as validate,
|
||||
WorkingDirectoryValidationAbortedError
|
||||
} from './working-directory-validation'
|
||||
|
||||
const directory = { isDirectory: () => true }
|
||||
const cwd = 'synthetic-cwd-wait-retention'
|
||||
|
||||
function pendingStat() {
|
||||
const gate = Promise.withResolvers<typeof directory>()
|
||||
stat.mockReturnValue(gate.promise)
|
||||
return gate
|
||||
}
|
||||
|
||||
async function canceledWait(path = cwd) {
|
||||
const controller = new AbortController()
|
||||
const signal = new WeakRef(controller.signal)
|
||||
const waiting = validate(path, { signal: controller.signal })
|
||||
controller.abort()
|
||||
try {
|
||||
await waiting
|
||||
throw new Error('Expected cancellation')
|
||||
} catch (error) {
|
||||
if (!(error instanceof WorkingDirectoryValidationAbortedError)) {
|
||||
throw error
|
||||
}
|
||||
expect(getEventListeners(controller.signal, 'abort')).toHaveLength(0)
|
||||
return { signal, error: new WeakRef(error) }
|
||||
}
|
||||
}
|
||||
|
||||
async function collect(): Promise<void> {
|
||||
if (!('gc' in globalThis) || typeof globalThis.gc !== 'function') {
|
||||
throw new Error('The test runner must enable --expose-gc')
|
||||
}
|
||||
for (let round = 0; round < 6; round += 1) {
|
||||
await new Promise<void>((resolve) => setImmediate(resolve))
|
||||
globalThis.gc()
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
stat.mockReset()
|
||||
_resetWorkingDirectoryValidationStateForTest()
|
||||
})
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
describe('working directory validation waiter lifetime', () => {
|
||||
it('releases the first caller and subsequent canceled callers while their native stat stays owned', async () => {
|
||||
const gate = pendingStat()
|
||||
try {
|
||||
const first = await canceledWait()
|
||||
const later: Awaited<ReturnType<typeof canceledWait>>[] = []
|
||||
for (let index = 0; index < 31; index += 1) {
|
||||
later.push(await canceledWait())
|
||||
}
|
||||
await collect()
|
||||
expect(first.signal.deref()).toBeUndefined()
|
||||
expect(first.error.deref()).toBeUndefined()
|
||||
expect(later.filter((ref) => ref.signal.deref() || ref.error.deref())).toHaveLength(0)
|
||||
expect(stat).toHaveBeenCalledOnce()
|
||||
|
||||
const staying = validate(cwd)
|
||||
expect(stat).toHaveBeenCalledOnce()
|
||||
gate.resolve(directory)
|
||||
await staying
|
||||
} finally {
|
||||
gate.resolve(directory)
|
||||
}
|
||||
})
|
||||
|
||||
it.each([false, true])(
|
||||
'cleans a successful or rejected live waiter: reject=%s',
|
||||
async (reject) => {
|
||||
const gate = pendingStat()
|
||||
const controller = new AbortController()
|
||||
const raw = validate(cwd)
|
||||
expect(validate(cwd)).toBe(raw)
|
||||
const rawResult = raw.catch((error: unknown) => error)
|
||||
const waiting = validate(cwd, { signal: controller.signal }).catch((error: unknown) => error)
|
||||
expect(getEventListeners(controller.signal, 'abort')).toHaveLength(1)
|
||||
if (reject) {
|
||||
gate.reject(new Error('Native stat failed'))
|
||||
} else {
|
||||
gate.resolve(directory)
|
||||
}
|
||||
const [rawValue, callerValue] = await Promise.all([rawResult, waiting])
|
||||
expect(callerValue).toBe(rawValue)
|
||||
expect(getEventListeners(controller.signal, 'abort')).toHaveLength(0)
|
||||
if (reject) {
|
||||
expect(callerValue).toBeInstanceOf(Error)
|
||||
} else {
|
||||
expect(callerValue).toBeUndefined()
|
||||
}
|
||||
stat.mockResolvedValue(directory)
|
||||
await validate(cwd)
|
||||
expect(stat).toHaveBeenCalledTimes(2)
|
||||
}
|
||||
)
|
||||
|
||||
it('preserves the raw operation when the first caller is already aborted', async () => {
|
||||
const gate = pendingStat()
|
||||
try {
|
||||
const signal = AbortSignal.abort()
|
||||
await expect(validate(cwd, { signal })).rejects.toBeInstanceOf(
|
||||
WorkingDirectoryValidationAbortedError
|
||||
)
|
||||
const first = validate(cwd)
|
||||
expect(validate(cwd)).toBe(first)
|
||||
expect(stat).toHaveBeenCalledOnce()
|
||||
expect(getEventListeners(signal, 'abort')).toHaveLength(0)
|
||||
gate.resolve(directory)
|
||||
await first
|
||||
} finally {
|
||||
gate.resolve(directory)
|
||||
}
|
||||
})
|
||||
|
||||
it('handles native rejection after every caller has already canceled', async () => {
|
||||
const unhandled: unknown[] = []
|
||||
const recordUnhandled = (error: unknown): void => {
|
||||
unhandled.push(error)
|
||||
}
|
||||
process.on('unhandledRejection', recordUnhandled)
|
||||
const gate = pendingStat()
|
||||
try {
|
||||
await canceledWait()
|
||||
gate.reject(new Error('Late native failure'))
|
||||
await new Promise<void>((resolve) => setImmediate(resolve))
|
||||
expect(unhandled).toEqual([])
|
||||
stat.mockResolvedValue(directory)
|
||||
await validate(cwd)
|
||||
expect(stat).toHaveBeenCalledTimes(2)
|
||||
} finally {
|
||||
gate.resolve(directory)
|
||||
process.off('unhandledRejection', recordUnhandled)
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps UNC slots occupied after caller cancellation until native settlement', async () => {
|
||||
const gates = Array.from({ length: 3 }, () => Promise.withResolvers<typeof directory>())
|
||||
let calls = 0
|
||||
stat.mockImplementation(() => {
|
||||
const gate = gates[calls++]
|
||||
if (!gate) {
|
||||
throw new Error('Unexpected native stat')
|
||||
}
|
||||
return gate.promise
|
||||
})
|
||||
try {
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
await canceledWait(`\\\\synthetic-host\\path-${index}`)
|
||||
}
|
||||
expect(stat).toHaveBeenCalledTimes(2)
|
||||
gates[0].resolve(directory)
|
||||
await new Promise<void>((resolve) => setImmediate(resolve))
|
||||
expect(stat).toHaveBeenCalledTimes(3)
|
||||
} finally {
|
||||
for (const gate of gates) {
|
||||
gate.resolve(directory)
|
||||
}
|
||||
await new Promise<void>((resolve) => setImmediate(resolve))
|
||||
}
|
||||
})
|
||||
|
||||
it.each(
|
||||
(['before', 'between', 'after'] as const).flatMap((position) =>
|
||||
[false, true].map((reject) => ({ position, reject }))
|
||||
)
|
||||
)(
|
||||
'preserves an external raw observer at $position with reject=$reject',
|
||||
async ({ position, reject }) => {
|
||||
const gate = pendingStat()
|
||||
const raw = validate(cwd)
|
||||
const controller = new AbortController()
|
||||
const start = () =>
|
||||
validate(cwd, { signal: controller.signal }).then(
|
||||
() => 'fulfilled',
|
||||
(error: unknown) => (error instanceof Error ? error.name : 'unknown')
|
||||
)
|
||||
const waiters: Promise<string>[] = []
|
||||
if (position !== 'before') {
|
||||
waiters.push(start())
|
||||
}
|
||||
const abortObserver = raw.then(
|
||||
() => controller.abort(),
|
||||
() => controller.abort()
|
||||
)
|
||||
if (position !== 'after') {
|
||||
waiters.push(start())
|
||||
}
|
||||
if (reject) {
|
||||
gate.reject(new Error('Native stat failed'))
|
||||
} else {
|
||||
gate.resolve(directory)
|
||||
}
|
||||
const rawOutcome = reject ? 'Error' : 'fulfilled'
|
||||
expect(await Promise.all(waiters)).toEqual(
|
||||
position === 'before'
|
||||
? ['WorkingDirectoryValidationAbortedError']
|
||||
: position === 'between'
|
||||
? [rawOutcome, 'WorkingDirectoryValidationAbortedError']
|
||||
: [rawOutcome]
|
||||
)
|
||||
await abortObserver
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -162,18 +162,7 @@ export function validateWorkingDirectoryAsync(
|
||||
if (!signal) {
|
||||
return validation
|
||||
}
|
||||
const shared = validation
|
||||
// The shared probe outlives this caller; keep it from surfacing as unhandled.
|
||||
void shared.catch(() => {})
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const onAbort = (): void => reject(new WorkingDirectoryValidationAbortedError(cwd))
|
||||
if (signal.aborted) {
|
||||
onAbort()
|
||||
return
|
||||
}
|
||||
signal.addEventListener('abort', onAbort, { once: true })
|
||||
shared.then(resolve, reject).finally(() => signal.removeEventListener('abort', onAbort))
|
||||
})
|
||||
return waitForWorkingDirectoryValidation(validation, cwd, signal)
|
||||
}
|
||||
|
||||
function validateWorkingDirectoryUncached(cwd: string): Promise<void> {
|
||||
@@ -202,3 +191,52 @@ async function probeWorkingDirectory(cwd: string): Promise<void> {
|
||||
throw new Error(`Working directory "${cwd}" is not a directory.`)
|
||||
}
|
||||
}
|
||||
|
||||
type WorkingDirectoryWaiterHolder = {
|
||||
waiter: {
|
||||
signal: AbortSignal
|
||||
onAbort: () => void
|
||||
resolve: () => void
|
||||
reject: (error: unknown) => void
|
||||
} | null
|
||||
}
|
||||
|
||||
function takeWorkingDirectoryWaiter(
|
||||
holder: WorkingDirectoryWaiterHolder
|
||||
): WorkingDirectoryWaiterHolder['waiter'] {
|
||||
const waiter = holder.waiter
|
||||
holder.waiter = null
|
||||
waiter?.signal.removeEventListener('abort', waiter.onAbort)
|
||||
return waiter
|
||||
}
|
||||
|
||||
// Keep reaction order while an abandoned caller's signal and resolver become collectible.
|
||||
function observeWorkingDirectoryValidation(
|
||||
promise: Promise<void>,
|
||||
holder: WorkingDirectoryWaiterHolder
|
||||
): void {
|
||||
void promise.then(
|
||||
() => takeWorkingDirectoryWaiter(holder)?.resolve(),
|
||||
(error: unknown) => takeWorkingDirectoryWaiter(holder)?.reject(error)
|
||||
)
|
||||
}
|
||||
|
||||
function waitForWorkingDirectoryValidation(
|
||||
shared: Promise<void>,
|
||||
cwd: string,
|
||||
signal: AbortSignal
|
||||
): Promise<void> {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const holder: WorkingDirectoryWaiterHolder = { waiter: null }
|
||||
const onAbort = (): void => {
|
||||
takeWorkingDirectoryWaiter(holder)?.reject(new WorkingDirectoryValidationAbortedError(cwd))
|
||||
}
|
||||
holder.waiter = { signal, onAbort, resolve, reject }
|
||||
if (signal.aborted) {
|
||||
onAbort()
|
||||
return
|
||||
}
|
||||
signal.addEventListener('abort', onAbort, { once: true })
|
||||
observeWorkingDirectoryValidation(shared, holder)
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user