diff --git a/packages/client-runtime/src/state/runtime.test.ts b/packages/client-runtime/src/state/runtime.test.ts index 63f00b28fb173..a31dfbdfeb0e7 100644 --- a/packages/client-runtime/src/state/runtime.test.ts +++ b/packages/client-runtime/src/state/runtime.test.ts @@ -110,6 +110,8 @@ const makeEnvironmentQueryHarness = Effect.fn("TestEnvironmentQuery.makeHarness" execute: Effect.Effect, { refreshTrigger, + refreshIntervalMs, + staleTimeMs, idleTtlMs, persistence, initialState, @@ -118,6 +120,8 @@ const makeEnvironmentQueryHarness = Effect.fn("TestEnvironmentQuery.makeHarness" onAnswer, }: { readonly refreshTrigger?: Atom.Atom; + readonly refreshIntervalMs?: number; + readonly staleTimeMs?: number; readonly onAnswer?: QueryAnswerHandler; readonly isFinal?: (value: A) => boolean; readonly idleTtlMs?: number; @@ -161,10 +165,11 @@ const makeEnvironmentQueryHarness = Effect.fn("TestEnvironmentQuery.makeHarness" ); const family = createEnvironmentQueryAtomFamily(runtime, { label: "test.environment-query", - staleTimeMs: 60_000, + staleTimeMs: staleTimeMs ?? 60_000, ...(persistence === undefined ? {} : { persistence }), execute: () => execute, ...(refreshTrigger === undefined ? {} : { refreshTrigger: () => refreshTrigger }), + ...(refreshIntervalMs === undefined ? {} : { refreshIntervalMs }), ...(idleTtlMs === undefined ? {} : { idleTtlMs }), ...(isFinal === undefined ? {} : { isFinal }), ...(onAnswer === undefined ? {} : { onAnswer }), @@ -206,6 +211,20 @@ const mountEnvironmentQuery = Effect.fn("TestEnvironmentQuery.mount")(function* return registry; }); +// Drain registry cleanup in microtasks so fake query intervals cannot overtake +// native setImmediate disposal when the clock advances by several intervals. +const makeTimerQueryRegistry = () => + AtomRegistry.make({ + scheduleTask: (run) => { + let cancelled = false; + queueMicrotask(() => { + if (!cancelled) run(); + }); + return () => { + cancelled = true; + }; + }, + }); it.effect("keeps complete review metadata visible while a refresh is held", () => Effect.gen(function* () { const started = yield* Deferred.make(); @@ -1131,6 +1150,416 @@ describe("environment query lifecycle", () => { }), ); + it.effect.each(["interval", "interval and signal"] as const)( + "polls only for real readers and retains cached reopen with %s", + (mode) => + Effect.scoped( + Effect.gen(function* () { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date"] }); + const registry = makeTimerQueryRegistry(); + yield* Effect.addFinalizer(() => + Effect.sync(() => { + registry.dispose(); + vi.useRealTimers(); + }), + ); + let executions = 0; + const answers = yield* Queue.make(); + const trigger = Atom.make(0).pipe(Atom.keepAlive); + const harness = yield* makeEnvironmentQueryHarness( + Effect.promise(() => Promise.resolve(++executions)).pipe( + Effect.tap((value) => Queue.offer(answers, value)), + ), + { + staleTimeMs: 0, + refreshIntervalMs: 10_000, + ...(mode === "interval" ? {} : { refreshTrigger: trigger }), + }, + ); + const read = readEnvironmentQuery(registry, harness.atom); + const first = registry.mount(harness.atom); + const second = registry.mount(harness.atom); + expect(yield* Queue.take(answers)).toBe(1); + expect(yield* read).toBe(1); + first(); + yield* Effect.promise(() => vi.advanceTimersByTimeAsync(10_000)); + expect(yield* Queue.take(answers)).toBe(2); + expect(yield* read).toBe(2); + + second(); + yield* Effect.promise(() => vi.advanceTimersByTimeAsync(0)); + yield* Effect.promise(() => vi.advanceTimersByTimeAsync(30_000)); + expect(executions).toBe(2); + + const remount = registry.mount(harness.atom); + expect(selectEnvironmentQueryData(registry.get(harness.atom))).toBe(2); + expect(yield* Queue.take(answers)).toBe(3); + expect(yield* read).toBe(3); + yield* Effect.promise(() => vi.advanceTimersByTimeAsync(10_000)); + expect(yield* Queue.take(answers)).toBe(4); + expect(yield* read).toBe(4); + if (mode === "interval and signal") { + registry.set(trigger, 1); + expect(yield* Queue.take(answers)).toBe(5); + expect(yield* read).toBe(5); + } + const beforeIdle = executions; + remount(); + yield* Effect.promise(() => vi.advanceTimersByTimeAsync(0)); + if (mode === "interval and signal") { + registry.set(trigger, 2); + registry.set(trigger, 3); + } + yield* Effect.promise(() => vi.advanceTimersByTimeAsync(30_000)); + expect(executions).toBe(beforeIdle); + const finalMount = registry.mount(harness.atom); + expect(selectEnvironmentQueryData(registry.get(harness.atom))).toBe(beforeIdle); + expect(yield* Queue.take(answers)).toBe(beforeIdle + 1); + expect(yield* read).toBe(beforeIdle + 1); + expect(executions).toBe(beforeIdle + 1); + finalMount(); + }), + ), + ); + + it.effect.each( + (["sync", "async", "failure", "previous success"] as const).flatMap((completion) => + [0, 30_000].map((idleMs) => ({ completion, idleMs })), + ), + )( + "revalidates once after an idle signal with $completion completion after $idleMs ms", + ({ completion, idleMs }) => + Effect.scoped( + Effect.gen(function* () { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date"] }); + const registry = makeTimerQueryRegistry(); + yield* Effect.addFinalizer(() => + Effect.sync(() => { + registry.dispose(); + vi.useRealTimers(); + }), + ); + let executions = 0; + const trigger = Atom.make(0).pipe(Atom.keepAlive); + const harness = yield* makeEnvironmentQueryHarness( + Effect.suspend(() => { + executions++; + if ( + completion === "failure" || + (completion === "previous success" && executions > 1) + ) { + return Effect.fail(new TestQueryError({ message: "query failed" })); + } + return completion === "async" + ? Effect.promise(() => Promise.resolve(executions)) + : Effect.succeed(executions); + }), + { refreshTrigger: trigger, refreshIntervalMs: 10_000, staleTimeMs: 0 }, + ); + const read = readEnvironmentQuery(registry, harness.atom).pipe(Effect.exit); + const unmount = registry.mount(harness.atom); + yield* read; + expect(executions).toBe(1); + unmount(); + yield* Effect.promise(() => vi.advanceTimersByTimeAsync(0)); + registry.set(trigger, 1); + yield* Effect.promise(() => vi.advanceTimersByTimeAsync(idleMs)); + expect(executions).toBe(1); + const remount = registry.mount(harness.atom); + yield* Effect.promise(() => vi.advanceTimersByTimeAsync(0)); + const result = yield* read; + if (completion === "failure" || completion === "previous success") { + expect(Exit.isFailure(result)).toBe(true); + if (Exit.isFailure(result)) { + expect(Cause.squash(result.cause)).toBeInstanceOf(TestQueryError); + } + expect(selectEnvironmentQueryData(registry.get(harness.atom))).toBe( + completion === "previous success" ? 1 : null, + ); + } else { + expect(result).toEqual(Exit.succeed(2)); + } + yield* Effect.promise(() => vi.advanceTimersByTimeAsync(0)); + expect(executions).toBe(2); + remount(); + }), + ), + ); + + it.effect.each([undefined, 10_000])( + "retains a disposable idle signal alongside cached data (interval %s)", + (refreshIntervalMs) => + Effect.scoped( + Effect.gen(function* () { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date"] }); + const registry = makeTimerQueryRegistry(); + yield* Effect.addFinalizer(() => + Effect.sync(() => { + registry.dispose(); + vi.useRealTimers(); + }), + ); + let executions = 0; + const trigger = Atom.make(0); + const harness = yield* makeEnvironmentQueryHarness( + Effect.sync(() => ++executions), + { + refreshTrigger: trigger, + ...(refreshIntervalMs === undefined ? {} : { refreshIntervalMs }), + staleTimeMs: 60_000, + }, + ); + const read = readEnvironmentQuery(registry, harness.atom); + const unmount = registry.mount(harness.atom); + expect(yield* read).toBe(1); + unmount(); + yield* Effect.promise(() => vi.advanceTimersByTimeAsync(0)); + registry.set(trigger, 1); + yield* Effect.promise(() => vi.advanceTimersByTimeAsync(0)); + registry.set(trigger, 2); + yield* Effect.promise(() => vi.advanceTimersByTimeAsync(30_000)); + expect(executions).toBe(1); + const remount = registry.mount(harness.atom); + expect(yield* read).toBe(2); + expect(executions).toBe(2); + remount(); + }), + ), + ); + + it.effect("revalidates cached data when remounted past its stale time", () => + Effect.scoped( + Effect.gen(function* () { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date"] }); + const registry = makeTimerQueryRegistry(); + yield* Effect.addFinalizer(() => + Effect.sync(() => { + registry.dispose(); + vi.useRealTimers(); + }), + ); + let executions = 0; + const harness = yield* makeEnvironmentQueryHarness( + Effect.sync(() => ++executions), + { staleTimeMs: 60_000 }, + ); + const unmount = registry.mount(harness.atom); + expect(yield* readEnvironmentQuery(registry, harness.atom)).toBe(1); + unmount(); + yield* Effect.promise(() => vi.advanceTimersByTimeAsync(61_000)); + const remount = registry.mount(harness.atom); + yield* Effect.promise(() => vi.advanceTimersByTimeAsync(0)); + expect(selectEnvironmentQueryData(registry.get(harness.atom))).toBe(2); + expect(executions).toBe(2); + remount(); + }), + ), + ); + + it.effect("revalidates a polled query once when remounted", () => + Effect.scoped( + Effect.gen(function* () { + // Registry tasks run only on flush, so a zero-delay timer can overtake a queued revalidation. + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date"] }); + const queued: Array<() => void> = []; + const registry = AtomRegistry.make({ + scheduleTask: (run) => { + queued.push(run); + return () => { + const index = queued.indexOf(run); + if (index >= 0) queued.splice(index, 1); + }; + }, + }); + yield* Effect.addFinalizer(() => + Effect.sync(() => { + registry.dispose(); + vi.useRealTimers(); + }), + ); + const flushTasks = () => { + while (queued.length > 0) queued.shift()?.(); + }; + let executions = 0; + const harness = yield* makeEnvironmentQueryHarness( + Effect.sync(() => ++executions), + { refreshIntervalMs: 10_000, staleTimeMs: 0 }, + ); + const unmount = registry.mount(harness.atom); + flushTasks(); + expect(selectEnvironmentQueryData(registry.get(harness.atom))).toBe(1); + unmount(); + flushTasks(); + yield* Effect.promise(() => vi.advanceTimersByTimeAsync(30_000)); + const remount = registry.mount(harness.atom); + yield* Effect.promise(() => vi.advanceTimersByTimeAsync(0)); + flushTasks(); + expect(executions).toBe(2); + remount(); + }), + ), + ); + + it.effect("queries afresh once after a polled query's cache expires", () => + Effect.scoped( + Effect.gen(function* () { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date"] }); + const registry = makeTimerQueryRegistry(); + yield* Effect.addFinalizer(() => + Effect.sync(() => { + registry.dispose(); + vi.useRealTimers(); + }), + ); + let executions = 0; + const harness = yield* makeEnvironmentQueryHarness( + Effect.sync(() => ++executions), + { refreshIntervalMs: 1_000, idleTtlMs: 5_000, staleTimeMs: 60_000 }, + ); + const read = readEnvironmentQuery(registry, harness.atom); + const unmount = registry.mount(harness.atom); + expect(yield* read).toBe(1); + unmount(); + yield* Effect.promise(() => vi.advanceTimersByTimeAsync(10_000)); + expect(executions).toBe(1); + const remount = registry.mount(harness.atom); + expect(yield* read).toBe(2); + yield* Effect.promise(() => vi.advanceTimersByTimeAsync(0)); + expect(executions).toBe(2); + remount(); + }), + ), + ); + + it.effect.each([ + { refreshIntervalMs: 60_000, idleTtlMs: 5_000 }, + { refreshIntervalMs: 30 * 60_000, idleTtlMs: 60 * 60_000 }, + ])("stops polling independently of cache TTL ($idleTtlMs)", (options) => + Effect.scoped( + Effect.gen(function* () { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date"] }); + const registry = makeTimerQueryRegistry(); + yield* Effect.addFinalizer(() => + Effect.sync(() => { + registry.dispose(); + vi.useRealTimers(); + }), + ); + let executions = 0; + const harness = yield* makeEnvironmentQueryHarness( + Effect.sync(() => ++executions), + { ...options, refreshTrigger: Atom.make(0).pipe(Atom.keepAlive) }, + ); + const unmount = registry.mount(harness.atom); + expect(yield* readEnvironmentQuery(registry, harness.atom)).toBe(1); + yield* Effect.promise(() => vi.advanceTimersByTimeAsync(options.refreshIntervalMs)); + expect(yield* readEnvironmentQuery(registry, harness.atom)).toBe(2); + unmount(); + yield* Effect.promise(() => vi.advanceTimersByTimeAsync(0)); + yield* Effect.promise(() => vi.advanceTimersByTimeAsync(3 * options.refreshIntervalMs)); + expect(executions).toBe(2); + }), + ), + ); + + it.effect("stops interval and signal refreshes for a final query result", () => + Effect.scoped( + Effect.gen(function* () { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date"] }); + const registry = makeTimerQueryRegistry(); + yield* Effect.addFinalizer(() => + Effect.sync(() => { + registry.dispose(); + vi.useRealTimers(); + }), + ); + let executions = 0; + const trigger = Atom.make(0).pipe(Atom.keepAlive); + const harness = yield* makeEnvironmentQueryHarness( + Effect.sync(() => ++executions), + { + refreshIntervalMs: 10_000, + refreshTrigger: trigger, + isFinal: (value) => value >= 2, + staleTimeMs: 0, + }, + ); + const unmount = registry.mount(harness.atom); + const read = readEnvironmentQuery(registry, harness.atom); + expect(yield* read).toBe(1); + yield* Effect.promise(() => vi.advanceTimersByTimeAsync(10_000)); + expect(yield* read).toBe(2); + registry.set(trigger, 1); + yield* Effect.promise(() => vi.advanceTimersByTimeAsync(30_000)); + expect(executions).toBe(2); + expect(selectEnvironmentQueryData(registry.get(harness.atom))).toBe(2); + unmount(); + yield* Effect.promise(() => vi.advanceTimersByTimeAsync(0)); + const remount = registry.mount(harness.atom); + expect(selectEnvironmentQueryData(registry.get(harness.atom))).toBe(2); + expect(yield* read).toBe(2); + expect(executions).toBe(2); + registry.refresh(harness.atom); + expect(yield* read).toBe(3); + yield* Effect.promise(() => vi.advanceTimersByTimeAsync(30_000)); + expect(executions).toBe(3); + remount(); + }), + ), + ); + + it.effect("cancels an obsolete periodic query when its session is replaced", () => + Effect.scoped( + Effect.gen(function* () { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date"] }); + const registry = makeTimerQueryRegistry(); + yield* Effect.addFinalizer(() => + Effect.sync(() => { + registry.dispose(); + vi.useRealTimers(); + }), + ); + let executions = 0; + const pendingStarted = Latch.makeUnsafe(); + const pendingStopped = Latch.makeUnsafe(); + const finishObsolete = Latch.makeUnsafe(); + const harness = yield* makeEnvironmentQueryHarness( + Effect.suspend(() => { + executions += 1; + if (executions !== 2) return Effect.succeed(executions); + pendingStarted.openUnsafe(); + return finishObsolete.await.pipe( + Effect.as(-1), + Effect.ensuring(Effect.sync(() => pendingStopped.openUnsafe())), + ); + }), + { refreshIntervalMs: 10_000, refreshTrigger: Atom.make(0).pipe(Atom.keepAlive) }, + ); + const observed: Array> = []; + const unmount = registry.subscribe(harness.atom, (result) => observed.push(result)); + const read = readEnvironmentQuery(registry, harness.atom); + expect(yield* read).toBe(1); + yield* Effect.promise(() => vi.advanceTimersByTimeAsync(10_000)); + yield* pendingStarted.await; + yield* SubscriptionRef.set( + harness.supervisorSession, + Option.some({ ...QUERY_RPC_SESSION }), + ); + expect(yield* read).toBe(3); + yield* pendingStopped.await; + finishObsolete.openUnsafe(); + expect(selectEnvironmentQueryData(registry.get(harness.atom))).toBe(3); + expect(observed.some(AsyncResult.isFailure)).toBe(false); + yield* Effect.promise(() => vi.advanceTimersByTimeAsync(10_000)); + expect(yield* read).toBe(4); + unmount(); + yield* Effect.promise(() => vi.advanceTimersByTimeAsync(0)); + yield* Effect.promise(() => vi.advanceTimersByTimeAsync(30_000)); + expect(executions).toBe(4); + }), + ), + ); + it.effect("filesystem browse lists the directory afresh when a picker reopens it", () => Effect.gen(function* () { const harness = yield* makeEnvironmentQueryHarness(Effect.void); diff --git a/packages/client-runtime/src/state/runtime.ts b/packages/client-runtime/src/state/runtime.ts index 736b10fdeeaca..21cedaa0b7c3d 100644 --- a/packages/client-runtime/src/state/runtime.ts +++ b/packages/client-runtime/src/state/runtime.ts @@ -542,19 +542,42 @@ const refreshOnSignalWhileRead = ); }; +/** + * Polls only while read: keep this outside every idle TTL so the timer ends with the last + * reader. A returning reader gets a poll it missed right away. + */ const refreshEveryUntilFinal = - (intervalMs: number, isFinal: (value: V) => boolean) => + >( + intervalMs: number, + isFinal: (value: V) => boolean, + ) => (self: Atom.Atom): Atom.Atom => Atom.transform( self, (get) => { - const value = get(self); - if (!isFinal(value)) { + const result = get(self); + if (isFinal(result)) return result; + const age = + Option.isNone(get.self()) && AsyncResult.isSuccess(result) && !result.waiting + ? // @effect-diagnostics-next-line globalDate:off - AsyncResult timestamps use wall time outside an Effect runtime. + Date.now() - result.timestamp + : 0; + if (age >= intervalMs) { + // Queued behind any revalidation this mount started; a refresh it makes rebuilds this + // node and cancels the catch-up. + let active = true; + get.addFinalizer(() => { + active = false; + }); + get.registry.schedulerAsync.makeDispatcher().scheduleTask(() => { + if (active) get.refresh(self); + }, 0); + } else { // @effect-diagnostics-next-line globalTimers:off - Mirrors `Atom.withRefresh`, outside an Effect runtime. - const handle = setTimeout(() => get.refresh(self), intervalMs); + const handle = setTimeout(() => get.refresh(self), intervalMs - age); get.addFinalizer(() => clearTimeout(handle)); } - return value; + return result; }, { initialValueTarget: self }, ); @@ -574,15 +597,24 @@ const isStale = (result: AsyncResult.AsyncResult, staleTimeMs: * It reads `self` as a dependency, not a subscription, so `self` goes idle with its last reader. */ const revalidateOnMount = - (staleTimeMs: number) => - ( - self: Atom.Atom>, - ): Atom.Atom> => + >( + staleTimeMs: number, + isFinal: (value: V) => boolean, + builds: WeakMap, + ) => + (self: Atom.Atom): Atom.Atom => Atom.transform( self, (get) => { + const before = builds.get(get.registry) ?? 0; const current = get(self); - if (Option.isNone(get.self()) && isStale(current, staleTimeMs)) { + const after = builds.get(get.registry) ?? 0; + if ( + Option.isNone(get.self()) && + before === after && + !isFinal(current) && + isStale(current, staleTimeMs) + ) { let active = true; get.addFinalizer(() => { active = false; @@ -590,7 +622,13 @@ const revalidateOnMount = // Deferred out of the build, which runs while React renders. A plain read drops this // node before the task runs, so only a mount refetches. get.registry.schedulerAsync.makeDispatcher().scheduleTask(() => { - if (active && isStale(get.once(self), staleTimeMs)) get.refresh(self); + if ( + active && + (builds.get(get.registry) ?? 0) === after && + !isFinal(get.once(self)) && + isStale(get.once(self), staleTimeMs) + ) + get.refresh(self); }, 0); } return current; @@ -722,6 +760,7 @@ export function createEnvironmentQueryAtomFamily( const family = Atom.family((key: string) => { const target = parseEnvironmentRpcKey(key); const idleTtlMs = options.idleTtlMs ?? 5 * 60_000; + const builds = new WeakMap(); const queryAtom = runtime .atom< A, @@ -730,6 +769,7 @@ export function createEnvironmentQueryAtomFamily( | EnvironmentRegistry.EnvironmentNotRegisteredError | EnvironmentRpcUnavailableError >((get) => { + builds.set(get.registry, (builds.get(get.registry) ?? 0) + 1); const connection = AsyncResult.getOrElse( get(connectionAtom(target.environmentId)), () => null, @@ -848,20 +888,20 @@ export function createEnvironmentQueryAtomFamily( .pipe(Atom.setIdleTTL(idleTtlMs)); const isFinal = (result: AsyncResult.AsyncResult) => options.isFinal !== undefined && Option.exists(AsyncResult.value(result), options.isFinal); - const intervalQuery = - options.refreshIntervalMs === undefined - ? queryAtom - : queryAtom.pipe(refreshEveryUntilFinal(options.refreshIntervalMs, isFinal)); const refreshTrigger = options.refreshTrigger?.(target); const cache = ( refreshTrigger === undefined - ? intervalQuery - : intervalQuery.pipe(refreshOnSignalWhileRead(refreshTrigger, isFinal)) + ? queryAtom + : queryAtom.pipe(refreshOnSignalWhileRead(refreshTrigger, isFinal)) ).pipe(Atom.setIdleTTL(idleTtlMs)); - const member = cache.pipe( - revalidateOnMount(options.staleTimeMs ?? 30_000), - Atom.withLabel(`${options.label}:${key}`), + const revalidated = cache.pipe( + revalidateOnMount(options.staleTimeMs ?? 30_000, isFinal, builds), ); + const member = ( + options.refreshIntervalMs === undefined + ? revalidated + : revalidated.pipe(refreshEveryUntilFinal(options.refreshIntervalMs, isFinal)) + ).pipe(Atom.withLabel(`${options.label}:${key}`)); membersByCache.set(cache, member); return member; });