diff --git a/packages/agent-bundle/src/effect/lift.ts b/packages/agent-bundle/src/effect/lift.ts index 5fec1275d..f1a437b97 100644 --- a/packages/agent-bundle/src/effect/lift.ts +++ b/packages/agent-bundle/src/effect/lift.ts @@ -1,15 +1,38 @@ import { Effect } from 'effect'; /** - * Lifts for the dev seam's existing Promise/sync helpers. Both keep the - * thrown/rejected value untouched in the error channel — the dev seam's + * Lifts for the dev seam's existing Promise/sync helpers (Stage 3). Both keep + * the thrown/rejected value untouched in the error channel — the dev seam's * typed contracts are plain `Error` subclasses that must cross * `src/effect/boundary.ts` identity-preserved, and several call sites * re-raise non-Error values (for example an `AbortSignal.reason`) verbatim. + * + * A bare `Effect.tryPromise(fn)` would wrap every rejection in + * `Cause.UnknownError`; these helpers are the only sanctioned way to lift a + * leaf helper (`docs/effect-conventions.md` § Stage 3 "Hurt / gotchas"). */ -export const liftPromise = (evaluate: () => PromiseLike): Effect.Effect => - Effect.tryPromise({ catch: (error) => error, try: evaluate }); +/** + * The raw value a lifted helper threw or rejected with. It is `unknown` by + * contract — the lift is an identity, not a normalizer — so callers narrow it + * where they know the helper's failure contract (`instanceof`, `isErrno`, + * `Effect.mapError` into a typed dev error) instead of assuming a shape. The + * boundary maps whatever reaches it: typed dev errors and `Error`s rethrow + * as-is, other values are wrapped, interruption becomes `AbortError`. + */ +export type LiftedRejection = unknown; + +/** + * Lift a Promise-returning leaf helper. `evaluate` receives Effect's + * interruption `AbortSignal` (aborted when the fiber is interrupted), so a + * cancellable API can be passed the signal directly; thunks that ignore it + * keep working unchanged. + */ +export const liftPromise = ( + evaluate: (signal: AbortSignal) => PromiseLike, +): Effect.Effect => + Effect.tryPromise({ catch: (error): LiftedRejection => error, try: evaluate }); -export const liftTry = (evaluate: () => A): Effect.Effect => - Effect.try({ catch: (error) => error, try: evaluate }); +/** Lift a synchronous leaf helper; a throw becomes a typed failure carrying the thrown value. */ +export const liftTry = (evaluate: () => A): Effect.Effect => + Effect.try({ catch: (error): LiftedRejection => error, try: evaluate }); diff --git a/packages/agent-bundle/tests/effect-boundary.test.ts b/packages/agent-bundle/tests/effect-boundary.test.ts index dcb3e26eb..40a7427fc 100644 --- a/packages/agent-bundle/tests/effect-boundary.test.ts +++ b/packages/agent-bundle/tests/effect-boundary.test.ts @@ -18,6 +18,7 @@ import { runSync, toDevError, } from '../src/effect/boundary.ts'; +import { liftPromise, liftTry } from '../src/effect/lift.ts'; import * as rootApi from '../src/index.ts'; describe('effect boundary (agent-bundle dev seam)', () => { @@ -90,3 +91,41 @@ describe('effect boundary (agent-bundle dev seam)', () => { await expect(runPromise(abortToInterrupt(controller.signal))).rejects.toSatisfy(isAbortError); }); }); + +describe('effect lifts (src/effect/lift.ts)', () => { + it('keeps the rejected or thrown value identity-preserved on the fail channel', async () => { + const typed = new EpochStoreError('EPOCH_NOT_FOUND', 'Epoch "e1" does not exist.'); + const reason = { code: 'ECUSTOM', message: 'not an Error' }; + const rejected = await runPromiseExit(liftPromise(() => Promise.reject(typed))); + expect(Exit.isFailure(rejected) && Cause.squash(rejected.cause)).toBe(typed); + const rawReason = await runPromiseExit(liftPromise(() => Promise.reject(reason))); + expect(Exit.isFailure(rawReason) && Cause.squash(rawReason.cause)).toBe(reason); + const thrown = await runPromiseExit(liftTry((): never => { throw typed; })); + expect(Exit.isFailure(thrown) && Cause.squash(thrown.cause)).toBe(typed); + expect(runSync(liftTry(() => 7))).toBe(7); + // The Promise edge rethrows typed errors as-is and wraps non-Error values, + // exactly per the boundary's mapping table — the lift itself never + // normalizes, so a caller that must re-raise a raw reason reads the Exit. + await expect(runPromise(liftPromise(() => Promise.reject(typed)))).rejects.toBe(typed); + await expect(runPromise(liftPromise(() => Promise.reject(reason)))).rejects.toEqual(new Error(String(reason))); + }); + + it('hands the lifted helper an AbortSignal that aborts when the fiber is interrupted', async () => { + const host = new AbortController(); + let observed: AbortSignal | undefined; + const pending = runPromise( + liftPromise((signal) => { + observed = signal; + return new Promise((_, reject) => { + signal.addEventListener('abort', () => reject(signal.reason), { once: true }); + }); + }), + { signal: host.signal }, + ); + await Promise.resolve(); + expect(observed?.aborted).toBe(false); + host.abort(); + await expect(pending).rejects.toSatisfy(isAbortError); + expect(observed?.aborted).toBe(true); + }); +}); diff --git a/packages/rsc-runtime/src/reconciler.ts b/packages/rsc-runtime/src/reconciler.ts index 9b9a7636b..5f6b4e61b 100644 --- a/packages/rsc-runtime/src/reconciler.ts +++ b/packages/rsc-runtime/src/reconciler.ts @@ -285,18 +285,23 @@ type SettledBoundary = { readonly ok: boolean; }; +/** + * Waits for the first pending boundary to settle either way. The rejection + * reason is data, not a failure to normalize: React rejects a boundary with a + * `{ message, digest }` object that `renderErrorFrom` reads, so mapping it + * through `toRuntimeError` would erase the digest. Both settlements are folded + * inside the promise, which therefore cannot reject and never puts an + * `unknown` on the Effect error channel. + */ const waitSettledBoundary = ( pending: readonly PendingBoundary[], -): Effect.Effect => +): Effect.Effect => Effect.raceAll( pending.map((boundary) => - Effect.tryPromise({ - catch: (error) => error, - try: () => Promise.resolve(boundary.thenable), - }).pipe( - Effect.map(() => ({ boundary, ok: true as const })), - Effect.catch((error) => Effect.succeed({ boundary, error, ok: false as const })), - ), + Effect.promise((): Promise => Promise.resolve(boundary.thenable).then( + () => ({ boundary, ok: true as const }), + (error: unknown) => ({ boundary, error, ok: false as const }), + )), ), );