diff --git a/core/build.ts b/core/build.ts index 1e28e1f..5a44b62 100644 --- a/core/build.ts +++ b/core/build.ts @@ -138,17 +138,27 @@ export async function build(opts: BuildConfig = {}, filesystem: Promise abortController.abort())` (registered LAST, after build) + // 1. `defer(() => abortController.abort())` (registered FIRST, below) + // 2. Plugins `scope.adopt(bgPromise, awaiter)` during build (registered mid-build) // - // LIFO disposal runs #2 first (abort cancels in-flight fetches), - // then #1 (awaiters drain settling promises incl. cache.put ops). - // This prevents leaked `op_cache_put` / `fetchCancelHandle` resources. + // LIFO disposal runs #2 first (adopt awaiters drain background + // promises, allowing in-flight `cache.put()` ops to complete + // naturally), then #1 (abort fires as final cleanup for anything + // still lingering). + // + // This prevents leaked `op_cache_put` / `fetchCancelHandle` / + // `CacheResponseResource` handles by ensuring all async work + // settles before the build boundary closes. const disposables = new AsyncDisposableStack(); const abortController = new AbortController(); - // NOTE: abort is registered AFTER the build completes (see below) - // so it sits at the TOP of the LIFO stack → fires FIRST on dispose. + // Register abort FIRST so it sits at the BOTTOM of the LIFO stack + // → fires LAST on dispose (after all adopted promises settle). + disposables.defer(async () => { + abortController.abort(); + // Yield a microtask to let Deno clean up aborted fetch handles + await Promise.resolve(); + }); const StateContext = new Context({ filesystem: Context.opaque(await filesystem), @@ -235,25 +245,6 @@ export async function build(opts: BuildConfig = {}, filesystem: Promise { - abortController.abort(); - // Yield a microtask to let Deno clean up aborted fetch handles - await Promise.resolve(); - }); - // ── Attach Explicit Resource Management symbols ──────────────────── // // Disposal is idempotent — `AsyncDisposableStack.disposeAsync()` @@ -270,7 +261,9 @@ export async function build(opts: BuildConfig = {}, filesystem: Promise abortController.abort())` (registered LAST, below) + // 1. `defer(() => abortController.abort())` (registered FIRST, below) + // 2. Plugins `scope.adopt(bgPromise, awaiter)` during rebuild (registered mid-rebuild) // - // LIFO disposal runs #2 first (abort cancels in-flight fetches), - // then #1 (awaiters drain settling promises incl. cache.put ops). + // LIFO disposal runs #2 first (adopt awaiters drain background + // promises, allowing in-flight `cache.put()` ops to complete + // naturally), then #1 (abort fires as final cleanup for anything + // still lingering). + // + // This prevents leaked `op_cache_put` / `fetchCancelHandle` / + // `CacheResponseResource` handles. const disposables = new AsyncDisposableStack(); const abortController = new AbortController(); - // NOTE: abort is registered AFTER context creation (see below) - // so it sits at the TOP of the LIFO stack → fires FIRST on dispose. + // Register abort FIRST so it sits at the BOTTOM of the LIFO stack + // → fires LAST on dispose (after all adopted promises settle). + disposables.defer(async () => { + abortController.abort(); + await Promise.resolve(); + }); const StateContext = new Context({ filesystem: Context.opaque(await filesystem), @@ -139,16 +148,8 @@ export async function context(opts: BuildConfig = {}, filesystem = TheFileSystem } else throw e; } - // Register abort as the LAST item on the stack so LIFO disposal - // fires it FIRST — cancelling in-flight fetches before we await - // background promises adopted during rebuilds. - // - // The defer is async with a microtask yield so Deno's runtime has a - // chance to clean up internal `fetchCancelHandle` resources. - disposables.defer(async () => { - abortController.abort(); - await Promise.resolve(); - }); + // abort was already registered at the BOTTOM of the LIFO stack + // (see top of function) — no second registration needed here. return { state: StateContext, @@ -173,7 +174,8 @@ export async function context(opts: BuildConfig = {}, filesystem = TheFileSystem }, } } catch (e) { - // Context creation failed — abort explicitly since defer(abort) wasn't registered yet. + // Context creation failed — abort is already registered in the defer, + // but we call it explicitly here for immediate cancellation. abortController.abort(); // Caller won't receive a disposable context — clean up before re-throwing. try { await disposables.disposeAsync(); } catch { /* don't mask the original error */ }