From a9362397b394cb70c9a9defe9b85a24ae067a93e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 15 May 2026 23:23:40 +0200 Subject: [PATCH] =?UTF-8?q?tests:=20#807=20=E2=80=94=20async-context=20tra?= =?UTF-8?q?cking=20harness=20(AsyncLocalStorage=20+=20async=5Fhooks)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eight independent sections probing the propagation rules Perry's `AsyncLocalStorage` / `async_hooks` need to follow: 1. Sync `run()` store baseline. 2. Single `await` to a separately-named async fn. 3. Chained awaits (depth 8). 4. `queueMicrotask` callback inside `run()`. 5. `setTimeout(fn, 0)` callback inside `run()`. 6. Nested `run()` with an await in each layer (sync outer / inner). 7. Concurrent `Promise.all` of two runs (isolation, no cross-pollination). 8. `createHook` lifecycle + `executionAsyncId` API shape. Node baseline (verified on Node v25.8): 16 stable lines, all sections PASS. Perry today (v0.5.912): - Sections 1, 6, and 7 already pass — sync nested runs and concurrent runs whose `getStore()` is called inline in the run body propagate correctly. - Sections 2/3/4/5 print `undefined` instead of the trace — the store evaporates when the await continuation is in a separately-named async function, or when the resume comes from queueMicrotask / setTimeout. - Section 8 — `createHook` returns undefined; the API is a name-only stub today. `known_failures.json` adds an entry pointing at #788 (AsyncLocalStorage real tracking) and #789 (async_hooks createHook lifecycle). Each section flips to PASS independently as those land. --- test-files/test_harness_async_context.ts | 147 +++++++++++++++++++++++ test-parity/known_failures.json | 1 + 2 files changed, 148 insertions(+) create mode 100644 test-files/test_harness_async_context.ts diff --git a/test-files/test_harness_async_context.ts b/test-files/test_harness_async_context.ts new file mode 100644 index 000000000..186304897 --- /dev/null +++ b/test-files/test_harness_async_context.ts @@ -0,0 +1,147 @@ +// #807 — real lifecycle harness for async_hooks + AsyncLocalStorage +// across `await`, microtasks, and timers. +// +// Today these APIs are name-only stubs in perry-stdlib (#788, #789): +// the constructors exist but `getStore()` doesn't actually track the +// active async context through await boundaries. This harness gives +// us a deterministic, byte-for-byte parity probe so we can watch the +// gap close as #788/#789 land — and catch regressions afterwards. +// +// Each section prints `section: result` so a parity diff pins the +// exact propagation rule that broke. + +import { AsyncLocalStorage, createHook, executionAsyncId } from "node:async_hooks"; + +// ── 1. Synchronous store propagation (baseline) ──────────────────────────── +// If this fails, nothing else can work — covers the AsyncLocalStorage +// shape itself, not propagation. +const als = new AsyncLocalStorage<{ trace: string }>(); +const syncResult = als.run({ trace: "sync" }, () => als.getStore()?.trace); +console.log("sync:", syncResult); + +// ── 2. Propagation through a single await ────────────────────────────────── +// `await` suspends and resumes the function inside a microtask; the +// store must follow the continuation. This is the #788 acceptance. +async function awaitedTrace(): Promise { + await Promise.resolve(); + return als.getStore()?.trace; +} +async function section2(): Promise { + const out = await als.run({ trace: "await" }, awaitedTrace); + console.log("await:", out); +} + +// ── 3. Propagation through chained awaits ────────────────────────────────── +// Each await is its own microtask; the chain must keep the store alive +// across all of them (no decay between the 1st and Nth). +async function chainedTrace(depth: number): Promise { + for (let i = 0; i < depth; i++) { + await Promise.resolve(); + } + return als.getStore()?.trace; +} +async function section3(): Promise { + const out = await als.run({ trace: "chain" }, () => chainedTrace(8)); + console.log("chain8:", out); +} + +// ── 4. Propagation through queueMicrotask ────────────────────────────────── +// Microtasks scheduled inside a run() must see the store. Node's +// AsyncLocalStorage tracks this through the async-resource bridge. +function microtaskTrace(): Promise { + return new Promise((resolve) => { + queueMicrotask(() => resolve(als.getStore()?.trace)); + }); +} +async function section4(): Promise { + const out = await als.run({ trace: "microtask" }, microtaskTrace); + console.log("microtask:", out); +} + +// ── 5. Propagation through setTimeout(fn, 0) ─────────────────────────────── +// Timers register an async resource and resume into a new tick; the +// store still has to follow. +function timerTrace(): Promise { + return new Promise((resolve) => { + setTimeout(() => resolve(als.getStore()?.trace), 0); + }); +} +async function section5(): Promise { + const out = await als.run({ trace: "timer" }, timerTrace); + console.log("timer:", out); +} + +// ── 6. Nested run() with await in between ────────────────────────────────── +// Outer store survives, inner shadows during its run, outer restored on +// inner's exit — across an await boundary. Catches the "store stack +// flattens on suspension" failure mode. +async function section6(): Promise { + await als.run({ trace: "outer6" }, async () => { + console.log("outer6 pre:", als.getStore()?.trace); + await als.run({ trace: "inner6" }, async () => { + await Promise.resolve(); + console.log("inner6:", als.getStore()?.trace); + }); + await Promise.resolve(); + console.log("outer6 post:", als.getStore()?.trace); + }); +} + +// ── 7. Concurrent runs interleaved by Promise.all ────────────────────────── +// Two awaited callbacks scheduled into the same tick must each see +// their own store, not cross-pollinate. Node guarantees per-callback +// isolation; this is where naive "single global active store" +// implementations leak. +async function section7(): Promise { + const fA = als.run({ trace: "A" }, async () => { + await Promise.resolve(); + return als.getStore()?.trace; + }); + const fB = als.run({ trace: "B" }, async () => { + await Promise.resolve(); + return als.getStore()?.trace; + }); + const results = await Promise.all([fA, fB]); + console.log("concurrent A:", results[0]); + console.log("concurrent B:", results[1]); +} + +// ── 8. async_hooks.createHook init/before/after/destroy lifecycle ────────── +// Hook callbacks must fire on real async resources. We don't assert +// counts (Node and Perry will differ on hidden internals); we assert +// shape: +// - createHook returns an object with the four methods. +// - hook.enable() and hook.disable() are no-ops we can call safely. +// - executionAsyncId() returns a positive integer. +// The whole point of #789 is that all four callbacks fire; today this +// section just exercises the API surface. +function section8(): void { + const hook = createHook({ + init() {}, + before() {}, + after() {}, + destroy() {}, + }); + console.log("createHook typeof:", typeof hook); + console.log("enable typeof:", typeof hook.enable); + console.log("disable typeof:", typeof hook.disable); + hook.enable(); + hook.disable(); + const id = executionAsyncId(); + console.log("executionAsyncId number:", typeof id === "number"); + console.log("executionAsyncId positive:", id >= 0); +} + +// ── Driver: run sections sequentially so output is deterministic ────────── +(async () => { + await section2(); + await section3(); + await section4(); + await section5(); + await section6(); + await section7(); + section8(); + console.log("harness: done"); +})().catch((e: unknown) => { + console.log("harness: ERROR", (e as Error).message); +}); diff --git a/test-parity/known_failures.json b/test-parity/known_failures.json index d14e58417..fcdf245bc 100644 --- a/test-parity/known_failures.json +++ b/test-parity/known_failures.json @@ -1,5 +1,6 @@ { "issue_655_repro": "issue #655 repro \u2014 kept as the canonical regression-catcher; will flip to PASS when #655 lands.", + "test_harness_async_context": "#807 harness for AsyncLocalStorage propagation through await/microtask/timer and async_hooks.createHook lifecycle. Today's Perry: sync nested run() works (sections 1, 6, 7) but the store evaporates when crossing an await boundary into a separately-named async fn (sections 2/3) and through queueMicrotask/setTimeout (sections 4/5). createHook returns undefined (section 8). Tracks #788 (AsyncLocalStorage real tracking) and #789 (async_hooks lifecycle); each section flips to PASS independently as those land.", "test_gap_array_methods": "Array method coverage probe \u2014 small divergences (e.g. flat/flatMap/finally specific edge cases). Targeted bisect needed; not a recent regression.", "test_issue_233_async_array_param_push": "Async array-param push edge case (#233) regresses on Linux CI but passes locally on macOS. Bisect to pin Linux-only divergence.", "test_issue_561_sigv4_chain": "SigV4 chain test (#561) regresses on Linux CI but passes locally on macOS. Likely related to crypto/HMAC ordering. Bisect needed.",