From c30aef6e77514704ba3c52e8ae1a19b91ca10859 Mon Sep 17 00:00:00 2001 From: Sky Hong Date: Mon, 5 Oct 2026 03:07:14 +0800 Subject: [PATCH 1/3] fix(web): keep background activity reporting after an interrupted report Each per-environment reportClientActivity request was wrapped in Effect.ignore, which swallows failures but not interruption. When an environment's connection is torn down while its report is in flight (for example across a sleep/wake), the interruption ended the report-consuming fiber, so the renderer never reported again. With no leases, provider-status work, including usage limit refreshes, stopped until the window was reloaded. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../lib/backgroundActivityReporter.test.ts | 65 ++++++++++++++++++- .../web/src/lib/backgroundActivityReporter.ts | 4 +- 2 files changed, 67 insertions(+), 2 deletions(-) diff --git a/apps/web/src/lib/backgroundActivityReporter.test.ts b/apps/web/src/lib/backgroundActivityReporter.test.ts index e32d991f9f53..68ccc18af0a7 100644 --- a/apps/web/src/lib/backgroundActivityReporter.test.ts +++ b/apps/web/src/lib/backgroundActivityReporter.test.ts @@ -1,8 +1,15 @@ +import { EnvironmentRegistry } from "@t3tools/client-runtime/connection"; import { EnvironmentId, WS_METHODS } from "@t3tools/contracts"; -import { describe, expect, it } from "@effect/vitest"; +import { afterEach, beforeEach, describe, expect, it } from "@effect/vitest"; +import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SubscriptionRef from "effect/SubscriptionRef"; +import * as TestClock from "effect/testing/TestClock"; +import { vi } from "vite-plus/test"; import { + backgroundActivityReporterLayer, observeBackgroundActivitySubscription, retainedBackgroundScopes, wasRecentlyInteracted, @@ -61,3 +68,59 @@ describe("wasRecentlyInteracted", () => { }), ); }); + +beforeEach(() => { + const target = new EventTarget(); + vi.stubGlobal("window", { + addEventListener: target.addEventListener.bind(target), + removeEventListener: target.removeEventListener.bind(target), + localStorage: { getItem: () => "client", setItem: () => undefined }, + }); + vi.stubGlobal("document", { + visibilityState: "visible", + hasFocus: () => true, + addEventListener: target.addEventListener.bind(target), + removeEventListener: target.removeEventListener.bind(target), + }); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("backgroundActivityReporterLayer", () => { + const keepsReportingAfter = (firstReport: Effect.Effect) => + Effect.gen(function* () { + const entries = yield* SubscriptionRef.make( + new Map([[EnvironmentId.make("environment-reporter-test"), {}]]) as never, + ); + let calls = 0; + const registry = EnvironmentRegistry.EnvironmentRegistry.of({ + entries, + run: () => { + calls += 1; + return calls === 1 ? firstReport : Effect.void; + }, + } as never); + + yield* Layer.build( + backgroundActivityReporterLayer.pipe( + Layer.provide(Layer.succeed(EnvironmentRegistry.EnvironmentRegistry, registry)), + ), + ); + yield* TestClock.adjust("1 second"); + expect(calls).toBe(1); + + yield* TestClock.adjust("60 seconds"); + expect(calls).toBeGreaterThan(1); + }).pipe(Effect.scoped); + + it.effect("keeps reporting after one report fails", () => + keepsReportingAfter(Effect.fail("unreachable")), + ); + + // As when an environment's connection is torn down while its report is in flight. + it.effect("keeps reporting after one report is interrupted", () => + keepsReportingAfter(Effect.failCause(Cause.interrupt())), + ); +}); diff --git a/apps/web/src/lib/backgroundActivityReporter.ts b/apps/web/src/lib/backgroundActivityReporter.ts index c646ac156d49..78c3d3b232f1 100644 --- a/apps/web/src/lib/backgroundActivityReporter.ts +++ b/apps/web/src/lib/backgroundActivityReporter.ts @@ -206,7 +206,9 @@ export const backgroundActivityReporterLayer = Layer.effectDiscard( createActivityReport(environmentId, lastInteractionAtMs, observedAtMs), ), ) - .pipe(Effect.ignore), + // An environment torn down mid-request interrupts its report; that must not + // end the reporting loop, or provider-status leases lapse until reload. + .pipe(Effect.ignoreCause), { concurrency: "unbounded", discard: true }, ); }).pipe(Effect.withSpan("web.backgroundActivity.report")); From bb3f160a3d37b69bbdc7caacc4dabee5ec510cf5 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 01:31:05 +0000 Subject: [PATCH 2/3] fix(web): keep reporting client activity after an interrupted report Trim the regression to one test against the renamed reporter layer and keep the comment on the constraint. Co-Authored-By: Claude Fable 5.1 --- .../lib/backgroundActivityReporter.test.ts | 61 ++++++++----------- .../web/src/lib/backgroundActivityReporter.ts | 4 +- 2 files changed, 27 insertions(+), 38 deletions(-) diff --git a/apps/web/src/lib/backgroundActivityReporter.test.ts b/apps/web/src/lib/backgroundActivityReporter.test.ts index cfb389e5b0c4..3dee04ec1718 100644 --- a/apps/web/src/lib/backgroundActivityReporter.test.ts +++ b/apps/web/src/lib/backgroundActivityReporter.test.ts @@ -9,7 +9,7 @@ import * as TestClock from "effect/testing/TestClock"; import { vi } from "vite-plus/test"; import { - backgroundActivityReporterLayer, + layer, observeBackgroundActivitySubscription, retainedBackgroundScopes, wasRecentlyInteracted, @@ -78,58 +78,47 @@ describe("wasRecentlyInteracted", () => { ); }); -beforeEach(() => { - const target = new EventTarget(); - vi.stubGlobal("window", { - addEventListener: target.addEventListener.bind(target), - removeEventListener: target.removeEventListener.bind(target), - localStorage: { getItem: () => "client", setItem: () => undefined }, +describe("layer", () => { + beforeEach(() => { + const target = new EventTarget(); + const listeners = { + addEventListener: target.addEventListener.bind(target), + removeEventListener: target.removeEventListener.bind(target), + }; + vi.stubGlobal("window", { + ...listeners, + localStorage: { getItem: () => "client", setItem: () => undefined }, + }); + vi.stubGlobal("document", { ...listeners, visibilityState: "visible", hasFocus: () => true }); }); - vi.stubGlobal("document", { - visibilityState: "visible", - hasFocus: () => true, - addEventListener: target.addEventListener.bind(target), - removeEventListener: target.removeEventListener.bind(target), - }); -}); -afterEach(() => { - vi.unstubAllGlobals(); -}); + afterEach(() => { + vi.unstubAllGlobals(); + }); -describe("backgroundActivityReporterLayer", () => { - const keepsReportingAfter = (firstReport: Effect.Effect) => + // As when an environment's connection is torn down while its report is in flight. + it.effect("keeps reporting after a report is interrupted", () => Effect.gen(function* () { const entries = yield* SubscriptionRef.make( - new Map([[EnvironmentId.make("environment-reporter-test"), {}]]) as never, + new Map([[EnvironmentId.make("environment-reporter-test"), {}]]), ); let calls = 0; const registry = EnvironmentRegistry.EnvironmentRegistry.of({ entries, run: () => { calls += 1; - return calls === 1 ? firstReport : Effect.void; + return calls === 1 ? Effect.failCause(Cause.interrupt()) : Effect.void; }, - } as never); + } as unknown as EnvironmentRegistry.EnvironmentRegistry["Service"]); yield* Layer.build( - backgroundActivityReporterLayer.pipe( - Layer.provide(Layer.succeed(EnvironmentRegistry.EnvironmentRegistry, registry)), - ), + layer.pipe(Layer.provide(Layer.succeed(EnvironmentRegistry.EnvironmentRegistry, registry))), ); yield* TestClock.adjust("1 second"); expect(calls).toBe(1); - yield* TestClock.adjust("60 seconds"); - expect(calls).toBeGreaterThan(1); - }).pipe(Effect.scoped); - - it.effect("keeps reporting after one report fails", () => - keepsReportingAfter(Effect.fail("unreachable")), - ); - - // As when an environment's connection is torn down while its report is in flight. - it.effect("keeps reporting after one report is interrupted", () => - keepsReportingAfter(Effect.failCause(Cause.interrupt())), + yield* TestClock.adjust("25 seconds"); + expect(calls).toBe(2); + }).pipe(Effect.scoped), ); }); diff --git a/apps/web/src/lib/backgroundActivityReporter.ts b/apps/web/src/lib/backgroundActivityReporter.ts index 9210c308ab93..3ae10997c557 100644 --- a/apps/web/src/lib/backgroundActivityReporter.ts +++ b/apps/web/src/lib/backgroundActivityReporter.ts @@ -205,8 +205,8 @@ export const layer = Layer.effectDiscard( createActivityReport(environmentId, lastInteractionAtMs, observedAtMs), ), ) - // An environment torn down mid-request interrupts its report; that must not - // end the reporting loop, or provider-status leases lapse until reload. + // Not `ignore`: tearing down an environment mid-request interrupts its report, + // and an interrupt leaking out here would end the reporting loop for good. .pipe(Effect.ignoreCause), { concurrency: "unbounded", discard: true }, ); From 50bbcdfcf46ef4edcaea2fa6a921398749919f86 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 01:39:08 +0000 Subject: [PATCH 3/3] fix(mobile): keep reporting client activity after an interrupted report The mobile reporter has the same loop and the same Effect.ignore gap. Co-Authored-By: Claude Fable 5.1 --- apps/mobile/src/connection/background-activity.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/connection/background-activity.ts b/apps/mobile/src/connection/background-activity.ts index 80e185b9e05d..d6cd2f0ca2de 100644 --- a/apps/mobile/src/connection/background-activity.ts +++ b/apps/mobile/src/connection/background-activity.ts @@ -81,7 +81,9 @@ export const layerReporter = Layer.effectDiscard( observedAt: DateTime.makeUnsafe(observedAtMs), }), ) - .pipe(Effect.ignore), + // Not `ignore`: tearing down an environment mid-request interrupts its report, + // and an interrupt leaking out here would end the reporting loop for good. + .pipe(Effect.ignoreCause), { concurrency: "unbounded", discard: true }, ); }).pipe(Effect.withSpan("mobile.backgroundActivity.report"));