From da89192f20b01ad5e1ece27c695b8f057e9132c5 Mon Sep 17 00:00:00 2001 From: Aaron Queen Date: Wed, 14 Oct 2026 17:55:20 -0600 Subject: [PATCH 1/4] fix(web): saving the thread list cache no longer re-encodes every thread on the UI thread --- .../environment-cache-store.test.ts | 2 +- .../src/connection/environment-cache-store.ts | 2 +- apps/web/src/connection/storage.ts | 24 +++- packages/client-runtime/src/platform/index.ts | 1 + .../src/platform/shellCacheEncoding.test.ts | 113 ++++++++++++++++++ .../src/platform/shellCacheEncoding.ts | 19 ++- 6 files changed, 153 insertions(+), 8 deletions(-) create mode 100644 packages/client-runtime/src/platform/shellCacheEncoding.test.ts rename apps/mobile/src/connection/shell-cache-encoding.ts => packages/client-runtime/src/platform/shellCacheEncoding.ts (77%) diff --git a/apps/mobile/src/connection/environment-cache-store.test.ts b/apps/mobile/src/connection/environment-cache-store.test.ts index 4514c20e8564..4c65f1ac18d0 100644 --- a/apps/mobile/src/connection/environment-cache-store.test.ts +++ b/apps/mobile/src/connection/environment-cache-store.test.ts @@ -2,6 +2,7 @@ import { ORCHESTRATION_CACHE_SCHEMA_VERSION, ORCHESTRATION_THREAD_CACHE_SCHEMA_VERSION, StoredOrchestrationShellSnapshot, + makeStoredShellSnapshotEncoder, } from "@t3tools/client-runtime/platform"; import { CommandId, @@ -26,7 +27,6 @@ import * as Option from "effect/Option"; import * as MobileDatabase from "../persistence/mobile-database"; import { make } from "./environment-cache-store"; -import { makeStoredShellSnapshotEncoder } from "./shell-cache-encoding"; const ENVIRONMENT_ID = EnvironmentId.make("environment-1"); const PROJECT_ID = ProjectId.make("project-1"); diff --git a/apps/mobile/src/connection/environment-cache-store.ts b/apps/mobile/src/connection/environment-cache-store.ts index 653aded1657d..682cb6383f85 100644 --- a/apps/mobile/src/connection/environment-cache-store.ts +++ b/apps/mobile/src/connection/environment-cache-store.ts @@ -3,6 +3,7 @@ import { ORCHESTRATION_THREAD_CACHE_SCHEMA_VERSION, StoredOrchestrationThreadSnapshot, Persistence, + makeStoredShellSnapshotEncoder, } from "@t3tools/client-runtime/platform"; import { type EnvironmentId, ServerConfig, VcsListRefsResult } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; @@ -11,7 +12,6 @@ import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as MobileDatabase from "../persistence/mobile-database"; -import { makeStoredShellSnapshotEncoder } from "./shell-cache-encoding"; import { attachProjectFaviconDatabase, projectFaviconDatabaseCache, diff --git a/apps/web/src/connection/storage.ts b/apps/web/src/connection/storage.ts index 8be169fccd45..02a48a31864a 100644 --- a/apps/web/src/connection/storage.ts +++ b/apps/web/src/connection/storage.ts @@ -15,6 +15,7 @@ import { setRoutesInCatalog, replaceCatalogValue, Persistence, + makeStoredShellSnapshotEncoder, } from "@t3tools/client-runtime/platform"; import { TokenStore } from "@t3tools/client-runtime/authorization"; import { @@ -68,7 +69,6 @@ const ConnectionCatalogDocumentJson = Schema.fromJsonString(ConnectionCatalogDoc const decodeConnectionCatalogDocument = Schema.decodeUnknownEffect(ConnectionCatalogDocumentJson); const encodeConnectionCatalogDocument = Schema.encodeEffect(ConnectionCatalogDocumentJson); const decodeStoredShellSnapshot = Schema.decodeUnknownEffect(StoredShellSnapshotJson); -const encodeStoredShellSnapshot = Schema.encodeEffect(StoredShellSnapshotJson); const decodeStoredThreadSnapshot = Schema.decodeUnknownEffect(StoredThreadSnapshotJson); const encodeStoredThreadSnapshot = Schema.encodeEffect(StoredThreadSnapshotJson); const decodeStoredServerConfig = Schema.decodeUnknownEffect(StoredServerConfigJson); @@ -601,6 +601,24 @@ export function makeBrowserGitHubRoutingPermissions( }); } +// A message task lets input and rendering run between encoding chunks. Unlike a zero-delay +// timer it is neither clamped to 4 ms after a few chained yields nor stretched to a second +// or more per chunk in a background tab. +const yieldToBrowserTask = Effect.callback((resume) => { + const channel = new MessageChannel(); + const onMessage = () => { + channel.port1.close(); + resume(Effect.void); + }; + channel.port1.addEventListener("message", onMessage, { once: true }); + channel.port1.start(); + channel.port2.postMessage(null); + return Effect.sync(() => { + channel.port1.removeEventListener("message", onMessage); + channel.port1.close(); + }); +}); + export const layer = Layer.effectContext( Effect.gen(function* () { const { handle: database } = yield* Effect.acquireRelease( @@ -609,6 +627,10 @@ export const layer = Layer.effectContext( ); const catalog = yield* makeCatalogStore(makeCatalogBackend(database)); const githubRoutingPermissions = makeBrowserGitHubRoutingPermissions(); + // Remembers each thread row's encoding, so a save only encodes rows that changed. + const encodeStoredShellSnapshot = makeStoredShellSnapshotEncoder({ + yieldToHost: yieldToBrowserTask, + }); const targetStore = Persistence.ConnectionTargetStore.of({ list: catalog.read.pipe( diff --git a/packages/client-runtime/src/platform/index.ts b/packages/client-runtime/src/platform/index.ts index 8480b2b142f1..18efb9be18bc 100644 --- a/packages/client-runtime/src/platform/index.ts +++ b/packages/client-runtime/src/platform/index.ts @@ -3,5 +3,6 @@ export * as Persistence from "./persistence.ts"; // Flat so consumers' inferred types can name it. export { ConnectionPersistenceError } from "./persistence.ts"; export * from "./orchestrationCache.ts"; +export * from "./shellCacheEncoding.ts"; export * as PlatformConnectionSource from "./source.ts"; export * from "./storageDocument.ts"; diff --git a/packages/client-runtime/src/platform/shellCacheEncoding.test.ts b/packages/client-runtime/src/platform/shellCacheEncoding.test.ts new file mode 100644 index 000000000000..e5e6b4e5b023 --- /dev/null +++ b/packages/client-runtime/src/platform/shellCacheEncoding.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from "@effect/vitest"; +import { + EnvironmentId, + ProjectId, + ProviderInstanceId, + ThreadId, + type OrchestrationV2ThreadShell, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import { + ORCHESTRATION_CACHE_SCHEMA_VERSION, + StoredOrchestrationShellSnapshot, +} from "./orchestrationCache.ts"; +import { makeStoredShellSnapshotEncoder } from "./shellCacheEncoding.ts"; + +const NOW = DateTime.makeUnsafe("2026-07-29T12:00:00.000Z"); +const PROJECT_ID = ProjectId.make("project-1"); +const PROVIDER_INSTANCE_ID = ProviderInstanceId.make("codex"); + +const makeThread = (index: number): OrchestrationV2ThreadShell => { + const id = ThreadId.make(`thread-${index}`); + return { + id, + projectId: PROJECT_ID, + title: `Thread ${index}`, + providerInstanceId: PROVIDER_INSTANCE_ID, + modelSelection: { instanceId: PROVIDER_INSTANCE_ID, model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + activeProviderThreadId: null, + lineage: { rootThreadId: id, parentThreadId: null, relationshipToParent: null }, + forkedFrom: null, + createdBy: "user", + creationSource: "web", + latestRunId: null, + activeRunId: null, + status: "idle", + pendingRuntimeRequest: null, + latestVisibleMessage: null, + latestUserMessageAt: NOW, + activityRunStartedAt: null, + activityRunStatus: null, + hasActionableProposedPlan: false, + itemCount: 0, + visibleItemCount: 0, + createdAt: NOW, + updatedAt: NOW, + archivedAt: null, + settledOverride: null, + settledAt: NOW, + unsettledAt: null, + lastVisitedAt: null, + deletedAt: null, + }; +}; + +const makeStored = (threads: ReadonlyArray) => ({ + schemaVersion: ORCHESTRATION_CACHE_SCHEMA_VERSION, + environmentId: EnvironmentId.make("environment-1"), + snapshot: { + schemaVersion: 1 as const, + snapshotSequence: 3, + projects: [], + threads, + archivedThreads: [], + }, +}); + +describe("shell cache encoder with a caller-supplied yield", () => { + const encodeOriginal = Schema.encodeEffect( + Schema.fromJsonString(StoredOrchestrationShellSnapshot), + ); + + it.effect("keeps output identical to the full encode when only one row changes", () => + Effect.gen(function* () { + let yields = 0; + const encode = makeStoredShellSnapshotEncoder({ + yieldToHost: Effect.sync(() => { + yields += 1; + }), + }); + const threads = Array.from({ length: 70 }, (_, index) => makeThread(index)); + const first = makeStored(threads); + expect(yield* encode(first)).toBe(yield* encodeOriginal(first)); + // 70 rows are three 32-row chunks, so the first save yields between them. + expect(yields).toBeGreaterThanOrEqual(2); + + const second = makeStored([{ ...threads[0]!, title: "Renamed" }, ...threads.slice(1)]); + expect(yield* encode(second)).toBe(yield* encodeOriginal(second)); + }), + ); + + it.effect("does not yield again when every row was already encoded", () => + Effect.gen(function* () { + let yields = 0; + const encode = makeStoredShellSnapshotEncoder({ + yieldToHost: Effect.sync(() => { + yields += 1; + }), + }); + const stored = makeStored(Array.from({ length: 70 }, (_, index) => makeThread(index))); + yield* encode(stored); + const afterFirst = yields; + yield* encode(makeStored(stored.snapshot.threads)); + expect(yields).toBe(afterFirst); + }), + ); +}); diff --git a/apps/mobile/src/connection/shell-cache-encoding.ts b/packages/client-runtime/src/platform/shellCacheEncoding.ts similarity index 77% rename from apps/mobile/src/connection/shell-cache-encoding.ts rename to packages/client-runtime/src/platform/shellCacheEncoding.ts index e5b4105ee438..3b929b5b7e01 100644 --- a/apps/mobile/src/connection/shell-cache-encoding.ts +++ b/packages/client-runtime/src/platform/shellCacheEncoding.ts @@ -1,13 +1,16 @@ -import { StoredOrchestrationShellSnapshot } from "@t3tools/client-runtime/platform"; +// @effect-diagnostics globalTimers:off globalTimersInEffect:off -- A host timer, not Effect.sleep, lets input and rendering run between row chunks. import { OrchestrationV2ThreadShellJson } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; +import { StoredOrchestrationShellSnapshot } from "./orchestrationCache.ts"; + const ROWS_PER_CHUNK = 32; -// Effect's cooperative yield resumes through microtasks on React Native. A host -// timer lets input and rendering run between chunks of shell rows instead. -const yieldToHost = Effect.callback((resume) => { +// Effect's cooperative yield resumes through microtasks, which run before the next +// frame on React Native and before input in a browser. A host timer lets input and +// rendering run between chunks of shell rows instead. +const yieldToHostTimer = Effect.callback((resume) => { const timer = setTimeout(() => resume(Effect.void), 0); return Effect.sync(() => clearTimeout(timer)); }); @@ -38,8 +41,14 @@ const encodeEnvelope = Schema.encodeEffect( * Shell rows are immutable and the shell reducer keeps unchanged rows by * reference, so each encoder remembers the canonical encoding of rows it has * already encoded successfully and only runs the row codec for new rows. + * + * `yieldToHost` defaults to a zero-delay timer. A browser should pass a yield + * that background-tab timer throttling cannot stretch to a second per chunk. */ -export function makeStoredShellSnapshotEncoder() { +export function makeStoredShellSnapshotEncoder( + options: { readonly yieldToHost?: Effect.Effect } = {}, +) { + const yieldToHost = options.yieldToHost ?? yieldToHostTimer; const encodedRows = new WeakMap(); return Effect.fnUntraced(function* (stored: typeof StoredOrchestrationShellSnapshot.Type) { From 814e1e3cb0bb20b515f6246fe8b0bc85e6e74867 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 15 Oct 2026 13:38:17 -0700 Subject: [PATCH 2/4] test(client-runtime): cover the shared shell cache encoder with byte equality and row reuse The encoder moved from mobile into client-runtime, so its tests move with it. Three cases remain: output matches the schema codec byte for byte across a warm save with changed, added and archived rows; an unchanged row's encoding is reused rather than re-encoded; and the default yield is a host timer. Co-Authored-By: Claude Opus 5.5 --- .../environment-cache-store.test.ts | 261 ------------------ .../src/platform/shellCacheEncoding.test.ts | 140 ++++------ 2 files changed, 54 insertions(+), 347 deletions(-) diff --git a/apps/mobile/src/connection/environment-cache-store.test.ts b/apps/mobile/src/connection/environment-cache-store.test.ts index 4c65f1ac18d0..17885e2ad255 100644 --- a/apps/mobile/src/connection/environment-cache-store.test.ts +++ b/apps/mobile/src/connection/environment-cache-store.test.ts @@ -1,8 +1,6 @@ import { ORCHESTRATION_CACHE_SCHEMA_VERSION, ORCHESTRATION_THREAD_CACHE_SCHEMA_VERSION, - StoredOrchestrationShellSnapshot, - makeStoredShellSnapshotEncoder, } from "@t3tools/client-runtime/platform"; import { CommandId, @@ -18,11 +16,7 @@ import { import { describe, expect, it } from "@effect/vitest"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; -import * as Fiber from "effect/Fiber"; import * as Logger from "effect/Logger"; -import * as Schema from "effect/Schema"; -import { vi } from "vite-plus/test"; -import * as Deferred from "effect/Deferred"; import * as Option from "effect/Option"; import * as MobileDatabase from "../persistence/mobile-database"; @@ -408,258 +402,3 @@ describe("mobile SQLite environment cache store", () => { }), ); }); - -describe("cooperative shell cache encoding", () => { - // A fresh encoder per call keeps these cases cold; the cache has its own cases below. - const encodeStoredShellSnapshot = (stored: typeof StoredOrchestrationShellSnapshot.Type) => - makeStoredShellSnapshotEncoder()(stored); - const encodeOriginal = Schema.encodeEffect( - Schema.fromJsonString(StoredOrchestrationShellSnapshot), - ); - const stored = { - schemaVersion: ORCHESTRATION_CACHE_SCHEMA_VERSION, - environmentId: ENVIRONMENT_ID, - snapshot: { - ...SHELL_SNAPSHOT, - projects: SHELL_SNAPSHOT.projects.map((project) => ({ - ...project, - title: " Project ", - unexpected: "drop this field", - })), - threads: Array.from({ length: 64 }, (_, index) => ({ - ...SHELL_SNAPSHOT.threads[0]!, - id: ThreadId.make(`thread-${index}`), - branch: " main ", - activityRunStartedAt: index % 2 === 0 ? NOW : null, - unsettledAt: index % 2 === 0 ? NOW : null, - modelSelection: { - instanceId: PROVIDER_INSTANCE_ID, - model: " gpt-5.4 ", - unexpected: "drop this field", - }, - unexpected: "drop this field", - })), - archivedThreads: [{ ...SHELL_SNAPSHOT.threads[0]!, archivedAt: NOW }], - unexpected: "drop this field", - }, - unexpected: "drop this field", - }; - - it.effect("preserves exact JSON, transforms, nulls, dates, and unknown-field stripping", () => - Effect.gen(function* () { - const expected = yield* encodeOriginal(stored); - const actual = yield* encodeStoredShellSnapshot(stored); - expect(actual).toBe(expected); - const parsed = JSON.parse(actual); - expect(parsed).not.toHaveProperty("unexpected"); - expect(parsed.snapshot).not.toHaveProperty("unexpected"); - expect(parsed.snapshot.projects[0]).not.toHaveProperty("unexpected"); - expect(parsed.snapshot.projects[0].title).toBe("Project"); - expect(parsed.snapshot.threads[0]).not.toHaveProperty("unexpected"); - expect(parsed.snapshot.threads[0].branch).toBe("main"); - expect(parsed.snapshot.threads[0].modelSelection).toEqual({ - instanceId: "codex", - model: "gpt-5.4", - }); - expect(parsed.snapshot.threads[0].activityRunStartedAt).toBe(DateTime.formatIso(NOW)); - expect(parsed.snapshot.threads[1].activityRunStartedAt).toBeNull(); - expect(parsed.snapshot.archivedThreads[0].archivedAt).toBe(DateTime.formatIso(NOW)); - }), - ); - - it.effect("preserves optional fields and dates across chunk boundaries and archived rows", () => - Effect.gen(function* () { - const base = SHELL_SNAPSHOT.threads[0]!; - const { titleRegeneration: _, unsettledAt: __, ...withoutOptional } = base; - const row = (index: number) => - index % 3 === 0 - ? { ...withoutOptional, id: ThreadId.make(`row-${index}`) } - : { - ...base, - id: ThreadId.make(`row-${index}`), - snoozedUntil: DateTime.add(NOW, { minutes: index }), - pinnedAt: index % 2 === 0 ? null : NOW, - }; - const mixed = { - ...stored, - snapshot: { - ...stored.snapshot, - threads: Array.from({ length: 65 }, (_, index) => row(index)), - archivedThreads: Array.from({ length: 33 }, (_, index) => ({ - ...row(index + 100), - archivedAt: DateTime.add(NOW, { hours: index }), - })), - }, - }; - const actual = yield* encodeStoredShellSnapshot(mixed); - expect(actual).toBe(yield* encodeOriginal(mixed)); - const parsed = JSON.parse(actual); - expect(parsed.snapshot.threads).toHaveLength(65); - expect(parsed.snapshot.threads[63]).not.toHaveProperty("titleRegeneration"); - expect(parsed.snapshot.threads[63]).not.toHaveProperty("unsettledAt"); - expect(parsed.snapshot.threads[64].snoozedUntil).toBe( - DateTime.formatIso(DateTime.add(NOW, { minutes: 64 })), - ); - expect(parsed.snapshot.archivedThreads).toHaveLength(33); - expect(parsed.snapshot.archivedThreads[32].archivedAt).toBe( - DateTime.formatIso(DateTime.add(NOW, { hours: 32 })), - ); - }), - ); - - it.effect("yields to a host timer between every 32-row chunk", () => - Effect.gen(function* () { - const setTimer = vi.spyOn(globalThis, "setTimeout"); - try { - yield* encodeStoredShellSnapshot({ - ...stored, - snapshot: { - ...stored.snapshot, - threads: Array.from({ length: 65 }, () => ({ ...stored.snapshot.threads[0]! })), - archivedThreads: Array.from({ length: 33 }, () => ({ - ...stored.snapshot.archivedThreads[0]!, - })), - }, - }); - // Five row chunks (3 active + 2 archived): four yields between them, one before the envelope. - expect(setTimer).toHaveBeenCalledTimes(5); - } finally { - vi.restoreAllMocks(); - } - }), - ); - - it.effect("still rejects invalid active and archived rows after an encoding chunk yields", () => - Effect.gen(function* () { - const invalidRow = { ...stored.snapshot.threads[0]!, itemCount: -1 }; - const invalidActive = { - ...stored, - snapshot: { ...stored.snapshot, threads: [...stored.snapshot.threads, invalidRow] }, - }; - const invalidArchived = { - ...stored, - snapshot: { ...stored.snapshot, archivedThreads: [invalidRow] }, - }; - const invalidEnvelope = { ...stored, snapshot: { ...stored.snapshot, snapshotSequence: -1 } }; - expect(yield* Effect.isFailure(encodeStoredShellSnapshot(invalidActive))).toBe(true); - expect(yield* Effect.isFailure(encodeStoredShellSnapshot(invalidArchived))).toBe(true); - expect(yield* Effect.isFailure(encodeOriginal(invalidEnvelope))).toBe(true); - expect(yield* Effect.isFailure(encodeStoredShellSnapshot(invalidEnvelope))).toBe(true); - }), - ); - - it.effect("keeps concurrent saves independent", () => - Effect.gen(function* () { - const other = { - ...stored, - environmentId: EnvironmentId.make("environment-2"), - snapshot: { - ...stored.snapshot, - threads: stored.snapshot.threads.slice(0, 40).map((thread) => ({ - ...thread, - title: `Other ${thread.id}`, - })), - archivedThreads: [], - }, - }; - const [first, second] = yield* Effect.all( - [encodeStoredShellSnapshot(stored), encodeStoredShellSnapshot(other)], - { concurrency: "unbounded" }, - ); - expect(first).toBe(yield* encodeOriginal(stored)); - expect(second).toBe(yield* encodeOriginal(other)); - }), - ); - - it.effect("cancels the pending host timer when encoding is interrupted", () => - Effect.gen(function* () { - vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); - const fakeSetTimeout = globalThis.setTimeout; - const scheduled = yield* Deferred.make>(); - vi.spyOn(globalThis, "setTimeout").mockImplementation((...args) => { - const timer = fakeSetTimeout(...args); - Deferred.doneUnsafe(scheduled, Effect.succeed(timer)); - return timer; - }); - const clearTimer = vi.spyOn(globalThis, "clearTimeout"); - const fiber = yield* Effect.forkScoped( - Effect.suspend(() => encodeStoredShellSnapshot(stored)), - ); - try { - const pendingTimer = yield* Deferred.await(scheduled); - expect(vi.getTimerCount()).toBe(1); - yield* Fiber.interrupt(fiber); - expect(clearTimer).toHaveBeenCalledWith(pendingTimer); - expect(vi.getTimerCount()).toBe(0); - } finally { - yield* Fiber.interrupt(fiber); - vi.restoreAllMocks(); - vi.useRealTimers(); - } - }), - ); - - it.effect("reuses encoded rows across saves without changing the payload", () => - Effect.gen(function* () { - const encode = makeStoredShellSnapshotEncoder(); - expect(yield* encode(stored)).toBe(yield* encodeOriginal(stored)); - - const setTimer = vi.spyOn(globalThis, "setTimeout"); - try { - // Same row references, new envelope: no row work, so no host yields. - const resaved = { - ...stored, - snapshot: { ...stored.snapshot, snapshotSequence: 4 }, - }; - expect(yield* encode(resaved)).toBe(yield* encodeOriginal(resaved)); - expect(setTimer).not.toHaveBeenCalled(); - } finally { - vi.restoreAllMocks(); - } - }), - ); - - it.effect("encodes replaced, added, and moved-to-archive rows on a warm encoder", () => - Effect.gen(function* () { - const encode = makeStoredShellSnapshotEncoder(); - yield* encode(stored); - const [first, second, ...rest] = stored.snapshot.threads; - const next = { - ...stored, - snapshot: { - ...stored.snapshot, - threads: [ - { ...first!, title: "Renamed", itemCount: 9 }, - ...rest, - { ...first!, id: ThreadId.make("thread-new"), branch: " feature " }, - ], - archivedThreads: [ - ...stored.snapshot.archivedThreads, - { ...second!, archivedAt: DateTime.add(NOW, { hours: 1 }) }, - ], - }, - }; - const actual = yield* encode(next); - expect(actual).toBe(yield* encodeOriginal(next)); - const parsed = JSON.parse(actual); - expect(parsed.snapshot.threads[0].title).toBe("Renamed"); - expect(parsed.snapshot.threads.at(-1).branch).toBe("feature"); - expect(parsed.snapshot.archivedThreads).toHaveLength(2); - }), - ); - - it.effect("does not cache rows from a failed encode", () => - Effect.gen(function* () { - const encode = makeStoredShellSnapshotEncoder(); - const invalidRow = { ...stored.snapshot.threads[0]!, itemCount: -1 }; - const invalid = { - ...stored, - snapshot: { ...stored.snapshot, threads: [...stored.snapshot.threads, invalidRow] }, - }; - expect(yield* Effect.isFailure(encode(invalid))).toBe(true); - expect(yield* Effect.isFailure(encode(invalid))).toBe(true); - // Valid rows from the failed save still encode correctly afterwards. - expect(yield* encode(stored)).toBe(yield* encodeOriginal(stored)); - }), - ); -}); diff --git a/packages/client-runtime/src/platform/shellCacheEncoding.test.ts b/packages/client-runtime/src/platform/shellCacheEncoding.test.ts index e5e6b4e5b023..84c53e2b6688 100644 --- a/packages/client-runtime/src/platform/shellCacheEncoding.test.ts +++ b/packages/client-runtime/src/platform/shellCacheEncoding.test.ts @@ -1,113 +1,81 @@ import { describe, expect, it } from "@effect/vitest"; -import { - EnvironmentId, - ProjectId, - ProviderInstanceId, - ThreadId, - type OrchestrationV2ThreadShell, -} from "@t3tools/contracts"; +import { EnvironmentId, ThreadId, type OrchestrationV2ThreadShell } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; +import { vi } from "vite-plus/test"; +import { v2Now, v2ShellSnapshot, v2ThreadShell } from "../state/orchestrationV2TestFixtures.ts"; import { ORCHESTRATION_CACHE_SCHEMA_VERSION, StoredOrchestrationShellSnapshot, } from "./orchestrationCache.ts"; import { makeStoredShellSnapshotEncoder } from "./shellCacheEncoding.ts"; -const NOW = DateTime.makeUnsafe("2026-07-29T12:00:00.000Z"); -const PROJECT_ID = ProjectId.make("project-1"); -const PROVIDER_INSTANCE_ID = ProviderInstanceId.make("codex"); - -const makeThread = (index: number): OrchestrationV2ThreadShell => { - const id = ThreadId.make(`thread-${index}`); - return { - id, - projectId: PROJECT_ID, - title: `Thread ${index}`, - providerInstanceId: PROVIDER_INSTANCE_ID, - modelSelection: { instanceId: PROVIDER_INSTANCE_ID, model: "gpt-5.4" }, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: null, - activeProviderThreadId: null, - lineage: { rootThreadId: id, parentThreadId: null, relationshipToParent: null }, - forkedFrom: null, - createdBy: "user", - creationSource: "web", - latestRunId: null, - activeRunId: null, - status: "idle", - pendingRuntimeRequest: null, - latestVisibleMessage: null, - latestUserMessageAt: NOW, - activityRunStartedAt: null, - activityRunStatus: null, - hasActionableProposedPlan: false, - itemCount: 0, - visibleItemCount: 0, - createdAt: NOW, - updatedAt: NOW, - archivedAt: null, - settledOverride: null, - settledAt: NOW, - unsettledAt: null, - lastVisitedAt: null, - deletedAt: null, - }; -}; +const encodeWithSchema = Schema.encodeEffect( + Schema.fromJsonString(StoredOrchestrationShellSnapshot), +); -const makeStored = (threads: ReadonlyArray) => ({ +const stored = ( + threads: ReadonlyArray, + archivedThreads: ReadonlyArray = [], +) => ({ schemaVersion: ORCHESTRATION_CACHE_SCHEMA_VERSION, environmentId: EnvironmentId.make("environment-1"), - snapshot: { - schemaVersion: 1 as const, - snapshotSequence: 3, - projects: [], - threads, - archivedThreads: [], - }, + snapshot: { ...v2ShellSnapshot, threads, archivedThreads }, +}); + +// Rows mix dates, omitted optional fields, and a branch the row codec trims. +const row = (index: number): OrchestrationV2ThreadShell => ({ + ...v2ThreadShell, + id: ThreadId.make(`thread-${index}`), + title: `Thread ${index}`, + branch: index % 2 === 0 ? " main " : null, + latestUserMessageAt: DateTime.add(v2Now, { minutes: index }), + ...(index % 3 === 0 ? { pinnedAt: v2Now, snoozedUntil: null } : {}), }); -describe("shell cache encoder with a caller-supplied yield", () => { - const encodeOriginal = Schema.encodeEffect( - Schema.fromJsonString(StoredOrchestrationShellSnapshot), +// More rows than fit in one encoding chunk. +const manyRows = () => Array.from({ length: 70 }, (_, index) => row(index)); + +describe("makeStoredShellSnapshotEncoder", () => { + it.effect("writes exactly what the schema codec writes, before and after rows change", () => + Effect.gen(function* () { + const encode = makeStoredShellSnapshotEncoder(); + const threads = manyRows(); + const first = stored(threads, [row(100)]); + expect(yield* encode(first)).toBe(yield* encodeWithSchema(first)); + + const [renamed, archived, ...rest] = threads; + const next = stored( + [{ ...renamed!, title: "Renamed" }, ...rest, row(70)], + [...first.snapshot.archivedThreads, { ...archived!, archivedAt: v2Now }], + ); + expect(yield* encode(next)).toBe(yield* encodeWithSchema(next)); + }), ); - it.effect("keeps output identical to the full encode when only one row changes", () => + it.effect("reuses the encoding of a row it has already saved", () => Effect.gen(function* () { - let yields = 0; - const encode = makeStoredShellSnapshotEncoder({ - yieldToHost: Effect.sync(() => { - yields += 1; - }), - }); - const threads = Array.from({ length: 70 }, (_, index) => makeThread(index)); - const first = makeStored(threads); - expect(yield* encode(first)).toBe(yield* encodeOriginal(first)); - // 70 rows are three 32-row chunks, so the first save yields between them. - expect(yields).toBeGreaterThanOrEqual(2); + const encode = makeStoredShellSnapshotEncoder(); + const kept = { ...row(0) }; + yield* encode(stored([kept])); - const second = makeStored([{ ...threads[0]!, title: "Renamed" }, ...threads.slice(1)]); - expect(yield* encode(second)).toBe(yield* encodeOriginal(second)); + // The encoder trusts rows to be immutable, so an in-place edit shows whether it re-encoded. + kept.title = "Edited in place"; + expect(yield* encode(stored([kept]))).toBe(yield* encodeWithSchema(stored([row(0)]))); }), ); - it.effect("does not yield again when every row was already encoded", () => + it.effect("yields to a host timer between chunks by default", () => Effect.gen(function* () { - let yields = 0; - const encode = makeStoredShellSnapshotEncoder({ - yieldToHost: Effect.sync(() => { - yields += 1; - }), - }); - const stored = makeStored(Array.from({ length: 70 }, (_, index) => makeThread(index))); - yield* encode(stored); - const afterFirst = yields; - yield* encode(makeStored(stored.snapshot.threads)); - expect(yields).toBe(afterFirst); + const setTimer = vi.spyOn(globalThis, "setTimeout"); + try { + yield* makeStoredShellSnapshotEncoder()(stored(manyRows())); + expect(setTimer).toHaveBeenCalled(); + } finally { + vi.restoreAllMocks(); + } }), ); }); From 400946b390d40d098c3d1532ebcc9e99fd96217e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 15 Oct 2026 14:13:11 -0700 Subject: [PATCH 3/4] test(client-runtime): move the shell cache encoder tests with the module The encoder moved from apps/mobile to packages/client-runtime, but the takeover's first pass replaced main's nine encoder tests with three new ones. Move main's `cooperative shell cache encoding` block verbatim into packages/client-runtime next to the encoder instead, keeping only the fixture it needs, and leave the mobile store tests as they are on main. Co-Authored-By: Claude Fable 5.1 --- .../src/platform/shellCacheEncoding.test.ts | 359 +++++++++++++++--- 1 file changed, 313 insertions(+), 46 deletions(-) diff --git a/packages/client-runtime/src/platform/shellCacheEncoding.test.ts b/packages/client-runtime/src/platform/shellCacheEncoding.test.ts index 84c53e2b6688..f1a393fb93bc 100644 --- a/packages/client-runtime/src/platform/shellCacheEncoding.test.ts +++ b/packages/client-runtime/src/platform/shellCacheEncoding.test.ts @@ -1,81 +1,348 @@ +import { + CommandId, + EnvironmentId, + ProjectId, + ProviderInstanceId, + ThreadId, + type OrchestrationV2ShellSnapshot, +} from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; -import { EnvironmentId, ThreadId, type OrchestrationV2ThreadShell } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as Schema from "effect/Schema"; import { vi } from "vite-plus/test"; +import * as Deferred from "effect/Deferred"; -import { v2Now, v2ShellSnapshot, v2ThreadShell } from "../state/orchestrationV2TestFixtures.ts"; import { ORCHESTRATION_CACHE_SCHEMA_VERSION, StoredOrchestrationShellSnapshot, } from "./orchestrationCache.ts"; import { makeStoredShellSnapshotEncoder } from "./shellCacheEncoding.ts"; -const encodeWithSchema = Schema.encodeEffect( - Schema.fromJsonString(StoredOrchestrationShellSnapshot), -); +const ENVIRONMENT_ID = EnvironmentId.make("environment-1"); +const PROJECT_ID = ProjectId.make("project-1"); +const THREAD_ID = ThreadId.make("thread-1"); +const PROVIDER_INSTANCE_ID = ProviderInstanceId.make("codex"); +const NOW = DateTime.makeUnsafe("2026-07-29T12:00:00.000Z"); +const SHELL_SNAPSHOT: OrchestrationV2ShellSnapshot = { + schemaVersion: 1, + snapshotSequence: 3, + projects: [ + { + id: PROJECT_ID, + title: "Project", + workspaceRoot: "/repo", + repositoryIdentity: null, + defaultModelSelection: null, + scripts: [], + createdAt: "2026-07-29T12:00:00.000Z", + updatedAt: "2026-07-29T12:00:00.000Z", + }, + ], + threads: [ + { + id: THREAD_ID, + projectId: PROJECT_ID, + title: "Thread", + providerInstanceId: PROVIDER_INSTANCE_ID, + modelSelection: { + instanceId: PROVIDER_INSTANCE_ID, + model: "gpt-5.4", + }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + activeProviderThreadId: null, + lineage: { + rootThreadId: THREAD_ID, + parentThreadId: null, + relationshipToParent: null, + }, + forkedFrom: null, + createdBy: "user", + creationSource: "mobile", + latestRunId: null, + activeRunId: null, + status: "idle", + pendingRuntimeRequest: null, + latestVisibleMessage: null, + latestUserMessageAt: NOW, + activityRunStartedAt: NOW, + activityRunStatus: "running", + hasActionableProposedPlan: false, + itemCount: 0, + visibleItemCount: 0, + createdAt: NOW, + updatedAt: NOW, + archivedAt: null, + settledOverride: null, + settledAt: NOW, + unsettledAt: NOW, + lastVisitedAt: null, + titleRegeneration: { + requestId: CommandId.make("title-regeneration-1"), + startedAt: NOW, + }, + deletedAt: null, + }, + ], + archivedThreads: [], +}; -const stored = ( - threads: ReadonlyArray, - archivedThreads: ReadonlyArray = [], -) => ({ - schemaVersion: ORCHESTRATION_CACHE_SCHEMA_VERSION, - environmentId: EnvironmentId.make("environment-1"), - snapshot: { ...v2ShellSnapshot, threads, archivedThreads }, -}); +describe("cooperative shell cache encoding", () => { + // A fresh encoder per call keeps these cases cold; the cache has its own cases below. + const encodeStoredShellSnapshot = (stored: typeof StoredOrchestrationShellSnapshot.Type) => + makeStoredShellSnapshotEncoder()(stored); + const encodeOriginal = Schema.encodeEffect( + Schema.fromJsonString(StoredOrchestrationShellSnapshot), + ); + const stored = { + schemaVersion: ORCHESTRATION_CACHE_SCHEMA_VERSION, + environmentId: ENVIRONMENT_ID, + snapshot: { + ...SHELL_SNAPSHOT, + projects: SHELL_SNAPSHOT.projects.map((project) => ({ + ...project, + title: " Project ", + unexpected: "drop this field", + })), + threads: Array.from({ length: 64 }, (_, index) => ({ + ...SHELL_SNAPSHOT.threads[0]!, + id: ThreadId.make(`thread-${index}`), + branch: " main ", + activityRunStartedAt: index % 2 === 0 ? NOW : null, + unsettledAt: index % 2 === 0 ? NOW : null, + modelSelection: { + instanceId: PROVIDER_INSTANCE_ID, + model: " gpt-5.4 ", + unexpected: "drop this field", + }, + unexpected: "drop this field", + })), + archivedThreads: [{ ...SHELL_SNAPSHOT.threads[0]!, archivedAt: NOW }], + unexpected: "drop this field", + }, + unexpected: "drop this field", + }; -// Rows mix dates, omitted optional fields, and a branch the row codec trims. -const row = (index: number): OrchestrationV2ThreadShell => ({ - ...v2ThreadShell, - id: ThreadId.make(`thread-${index}`), - title: `Thread ${index}`, - branch: index % 2 === 0 ? " main " : null, - latestUserMessageAt: DateTime.add(v2Now, { minutes: index }), - ...(index % 3 === 0 ? { pinnedAt: v2Now, snoozedUntil: null } : {}), -}); + it.effect("preserves exact JSON, transforms, nulls, dates, and unknown-field stripping", () => + Effect.gen(function* () { + const expected = yield* encodeOriginal(stored); + const actual = yield* encodeStoredShellSnapshot(stored); + expect(actual).toBe(expected); + const parsed = JSON.parse(actual); + expect(parsed).not.toHaveProperty("unexpected"); + expect(parsed.snapshot).not.toHaveProperty("unexpected"); + expect(parsed.snapshot.projects[0]).not.toHaveProperty("unexpected"); + expect(parsed.snapshot.projects[0].title).toBe("Project"); + expect(parsed.snapshot.threads[0]).not.toHaveProperty("unexpected"); + expect(parsed.snapshot.threads[0].branch).toBe("main"); + expect(parsed.snapshot.threads[0].modelSelection).toEqual({ + instanceId: "codex", + model: "gpt-5.4", + }); + expect(parsed.snapshot.threads[0].activityRunStartedAt).toBe(DateTime.formatIso(NOW)); + expect(parsed.snapshot.threads[1].activityRunStartedAt).toBeNull(); + expect(parsed.snapshot.archivedThreads[0].archivedAt).toBe(DateTime.formatIso(NOW)); + }), + ); -// More rows than fit in one encoding chunk. -const manyRows = () => Array.from({ length: 70 }, (_, index) => row(index)); + it.effect("preserves optional fields and dates across chunk boundaries and archived rows", () => + Effect.gen(function* () { + const base = SHELL_SNAPSHOT.threads[0]!; + const { titleRegeneration: _, unsettledAt: __, ...withoutOptional } = base; + const row = (index: number) => + index % 3 === 0 + ? { ...withoutOptional, id: ThreadId.make(`row-${index}`) } + : { + ...base, + id: ThreadId.make(`row-${index}`), + snoozedUntil: DateTime.add(NOW, { minutes: index }), + pinnedAt: index % 2 === 0 ? null : NOW, + }; + const mixed = { + ...stored, + snapshot: { + ...stored.snapshot, + threads: Array.from({ length: 65 }, (_, index) => row(index)), + archivedThreads: Array.from({ length: 33 }, (_, index) => ({ + ...row(index + 100), + archivedAt: DateTime.add(NOW, { hours: index }), + })), + }, + }; + const actual = yield* encodeStoredShellSnapshot(mixed); + expect(actual).toBe(yield* encodeOriginal(mixed)); + const parsed = JSON.parse(actual); + expect(parsed.snapshot.threads).toHaveLength(65); + expect(parsed.snapshot.threads[63]).not.toHaveProperty("titleRegeneration"); + expect(parsed.snapshot.threads[63]).not.toHaveProperty("unsettledAt"); + expect(parsed.snapshot.threads[64].snoozedUntil).toBe( + DateTime.formatIso(DateTime.add(NOW, { minutes: 64 })), + ); + expect(parsed.snapshot.archivedThreads).toHaveLength(33); + expect(parsed.snapshot.archivedThreads[32].archivedAt).toBe( + DateTime.formatIso(DateTime.add(NOW, { hours: 32 })), + ); + }), + ); -describe("makeStoredShellSnapshotEncoder", () => { - it.effect("writes exactly what the schema codec writes, before and after rows change", () => + it.effect("yields to a host timer between every 32-row chunk", () => Effect.gen(function* () { - const encode = makeStoredShellSnapshotEncoder(); - const threads = manyRows(); - const first = stored(threads, [row(100)]); - expect(yield* encode(first)).toBe(yield* encodeWithSchema(first)); + const setTimer = vi.spyOn(globalThis, "setTimeout"); + try { + yield* encodeStoredShellSnapshot({ + ...stored, + snapshot: { + ...stored.snapshot, + threads: Array.from({ length: 65 }, () => ({ ...stored.snapshot.threads[0]! })), + archivedThreads: Array.from({ length: 33 }, () => ({ + ...stored.snapshot.archivedThreads[0]!, + })), + }, + }); + // Five row chunks (3 active + 2 archived): four yields between them, one before the envelope. + expect(setTimer).toHaveBeenCalledTimes(5); + } finally { + vi.restoreAllMocks(); + } + }), + ); - const [renamed, archived, ...rest] = threads; - const next = stored( - [{ ...renamed!, title: "Renamed" }, ...rest, row(70)], - [...first.snapshot.archivedThreads, { ...archived!, archivedAt: v2Now }], - ); - expect(yield* encode(next)).toBe(yield* encodeWithSchema(next)); + it.effect("still rejects invalid active and archived rows after an encoding chunk yields", () => + Effect.gen(function* () { + const invalidRow = { ...stored.snapshot.threads[0]!, itemCount: -1 }; + const invalidActive = { + ...stored, + snapshot: { ...stored.snapshot, threads: [...stored.snapshot.threads, invalidRow] }, + }; + const invalidArchived = { + ...stored, + snapshot: { ...stored.snapshot, archivedThreads: [invalidRow] }, + }; + const invalidEnvelope = { ...stored, snapshot: { ...stored.snapshot, snapshotSequence: -1 } }; + expect(yield* Effect.isFailure(encodeStoredShellSnapshot(invalidActive))).toBe(true); + expect(yield* Effect.isFailure(encodeStoredShellSnapshot(invalidArchived))).toBe(true); + expect(yield* Effect.isFailure(encodeOriginal(invalidEnvelope))).toBe(true); + expect(yield* Effect.isFailure(encodeStoredShellSnapshot(invalidEnvelope))).toBe(true); }), ); - it.effect("reuses the encoding of a row it has already saved", () => + it.effect("keeps concurrent saves independent", () => Effect.gen(function* () { - const encode = makeStoredShellSnapshotEncoder(); - const kept = { ...row(0) }; - yield* encode(stored([kept])); + const other = { + ...stored, + environmentId: EnvironmentId.make("environment-2"), + snapshot: { + ...stored.snapshot, + threads: stored.snapshot.threads.slice(0, 40).map((thread) => ({ + ...thread, + title: `Other ${thread.id}`, + })), + archivedThreads: [], + }, + }; + const [first, second] = yield* Effect.all( + [encodeStoredShellSnapshot(stored), encodeStoredShellSnapshot(other)], + { concurrency: "unbounded" }, + ); + expect(first).toBe(yield* encodeOriginal(stored)); + expect(second).toBe(yield* encodeOriginal(other)); + }), + ); - // The encoder trusts rows to be immutable, so an in-place edit shows whether it re-encoded. - kept.title = "Edited in place"; - expect(yield* encode(stored([kept]))).toBe(yield* encodeWithSchema(stored([row(0)]))); + it.effect("cancels the pending host timer when encoding is interrupted", () => + Effect.gen(function* () { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + const fakeSetTimeout = globalThis.setTimeout; + const scheduled = yield* Deferred.make>(); + vi.spyOn(globalThis, "setTimeout").mockImplementation((...args) => { + // @effect-diagnostics-next-line globalTimers:off - Observes the encoder's own host timer. + const timer = fakeSetTimeout(...args); + Deferred.doneUnsafe(scheduled, Effect.succeed(timer)); + return timer; + }); + const clearTimer = vi.spyOn(globalThis, "clearTimeout"); + const fiber = yield* Effect.forkScoped( + Effect.suspend(() => encodeStoredShellSnapshot(stored)), + ); + try { + const pendingTimer = yield* Deferred.await(scheduled); + expect(vi.getTimerCount()).toBe(1); + yield* Fiber.interrupt(fiber); + expect(clearTimer).toHaveBeenCalledWith(pendingTimer); + expect(vi.getTimerCount()).toBe(0); + } finally { + yield* Fiber.interrupt(fiber); + vi.restoreAllMocks(); + vi.useRealTimers(); + } }), ); - it.effect("yields to a host timer between chunks by default", () => + it.effect("reuses encoded rows across saves without changing the payload", () => Effect.gen(function* () { + const encode = makeStoredShellSnapshotEncoder(); + expect(yield* encode(stored)).toBe(yield* encodeOriginal(stored)); + const setTimer = vi.spyOn(globalThis, "setTimeout"); try { - yield* makeStoredShellSnapshotEncoder()(stored(manyRows())); - expect(setTimer).toHaveBeenCalled(); + // Same row references, new envelope: no row work, so no host yields. + const resaved = { + ...stored, + snapshot: { ...stored.snapshot, snapshotSequence: 4 }, + }; + expect(yield* encode(resaved)).toBe(yield* encodeOriginal(resaved)); + expect(setTimer).not.toHaveBeenCalled(); } finally { vi.restoreAllMocks(); } }), ); + + it.effect("encodes replaced, added, and moved-to-archive rows on a warm encoder", () => + Effect.gen(function* () { + const encode = makeStoredShellSnapshotEncoder(); + yield* encode(stored); + const [first, second, ...rest] = stored.snapshot.threads; + const next = { + ...stored, + snapshot: { + ...stored.snapshot, + threads: [ + { ...first!, title: "Renamed", itemCount: 9 }, + ...rest, + { ...first!, id: ThreadId.make("thread-new"), branch: " feature " }, + ], + archivedThreads: [ + ...stored.snapshot.archivedThreads, + { ...second!, archivedAt: DateTime.add(NOW, { hours: 1 }) }, + ], + }, + }; + const actual = yield* encode(next); + expect(actual).toBe(yield* encodeOriginal(next)); + const parsed = JSON.parse(actual); + expect(parsed.snapshot.threads[0].title).toBe("Renamed"); + expect(parsed.snapshot.threads.at(-1).branch).toBe("feature"); + expect(parsed.snapshot.archivedThreads).toHaveLength(2); + }), + ); + + it.effect("does not cache rows from a failed encode", () => + Effect.gen(function* () { + const encode = makeStoredShellSnapshotEncoder(); + const invalidRow = { ...stored.snapshot.threads[0]!, itemCount: -1 }; + const invalid = { + ...stored, + snapshot: { ...stored.snapshot, threads: [...stored.snapshot.threads, invalidRow] }, + }; + expect(yield* Effect.isFailure(encode(invalid))).toBe(true); + expect(yield* Effect.isFailure(encode(invalid))).toBe(true); + // Valid rows from the failed save still encode correctly afterwards. + expect(yield* encode(stored)).toBe(yield* encodeOriginal(stored)); + }), + ); }); From fc8ccdb487d3cc4482cfab3b9ec2605334070bea Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 15 Oct 2026 14:13:13 -0700 Subject: [PATCH 4/4] fix(web): yield through a message task while encoding the thread list cache Keep the encoder's zero-delay timer as the default yield so mobile keeps its behavior without passing anything, and scope the timer diagnostic suppression to the one call instead of the whole module. Correct the comment: Effect's own yield goes through setImmediate where the host has it, which on React Native is a microtask shim. On web, a MessageChannel message resumes encoding in a fresh task, so input and rendering get a turn between chunks without the 4 ms nested timer clamp or background-tab throttling. Closing the port on interrupt is enough cleanup; a closed port delivers nothing. Co-Authored-By: Claude Fable 5.1 --- apps/web/src/connection/storage.ts | 31 ++++++++++--------- .../src/platform/shellCacheEncoding.ts | 12 +++---- 2 files changed, 22 insertions(+), 21 deletions(-) diff --git a/apps/web/src/connection/storage.ts b/apps/web/src/connection/storage.ts index 02a48a31864a..e56bf7b7b89e 100644 --- a/apps/web/src/connection/storage.ts +++ b/apps/web/src/connection/storage.ts @@ -601,22 +601,23 @@ export function makeBrowserGitHubRoutingPermissions( }); } -// A message task lets input and rendering run between encoding chunks. Unlike a zero-delay -// timer it is neither clamped to 4 ms after a few chained yields nor stretched to a second -// or more per chunk in a background tab. +// A posted message resumes in a fresh task, so input and rendering get a turn between +// encoding chunks. A zero-delay timer would not do: browsers clamp nested timers to +// 4 ms and throttle them to once a second or less in a background tab. const yieldToBrowserTask = Effect.callback((resume) => { - const channel = new MessageChannel(); - const onMessage = () => { - channel.port1.close(); - resume(Effect.void); - }; - channel.port1.addEventListener("message", onMessage, { once: true }); - channel.port1.start(); - channel.port2.postMessage(null); - return Effect.sync(() => { - channel.port1.removeEventListener("message", onMessage); - channel.port1.close(); - }); + const { port1, port2 } = new MessageChannel(); + port1.addEventListener( + "message", + () => { + port1.close(); + resume(Effect.void); + }, + { once: true }, + ); + port1.start(); + port2.postMessage(null); + // A closed port delivers nothing, so interruption needs no listener bookkeeping. + return Effect.sync(() => port1.close()); }); export const layer = Layer.effectContext( diff --git a/packages/client-runtime/src/platform/shellCacheEncoding.ts b/packages/client-runtime/src/platform/shellCacheEncoding.ts index 3b929b5b7e01..da6b3c98617f 100644 --- a/packages/client-runtime/src/platform/shellCacheEncoding.ts +++ b/packages/client-runtime/src/platform/shellCacheEncoding.ts @@ -1,4 +1,3 @@ -// @effect-diagnostics globalTimers:off globalTimersInEffect:off -- A host timer, not Effect.sleep, lets input and rendering run between row chunks. import { OrchestrationV2ThreadShellJson } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; @@ -7,10 +6,12 @@ import { StoredOrchestrationShellSnapshot } from "./orchestrationCache.ts"; const ROWS_PER_CHUNK = 32; -// Effect's cooperative yield resumes through microtasks, which run before the next -// frame on React Native and before input in a browser. A host timer lets input and -// rendering run between chunks of shell rows instead. +// Effect's own yield resumes through `setImmediate` where the host defines it and a +// zero-delay timer otherwise. React Native's `setImmediate` is a microtask shim, so +// nothing from the host runs before the fiber resumes. A timer yields on every host; +// a browser can pass a yield that its timer clamping and throttling do not slow. const yieldToHostTimer = Effect.callback((resume) => { + // @effect-diagnostics-next-line globalTimersInEffect:off - A host timer, not Effect.sleep, is the point. const timer = setTimeout(() => resume(Effect.void), 0); return Effect.sync(() => clearTimeout(timer)); }); @@ -42,8 +43,7 @@ const encodeEnvelope = Schema.encodeEffect( * reference, so each encoder remembers the canonical encoding of rows it has * already encoded successfully and only runs the row codec for new rows. * - * `yieldToHost` defaults to a zero-delay timer. A browser should pass a yield - * that background-tab timer throttling cannot stretch to a second per chunk. + * `yieldToHost` defaults to a zero-delay timer. */ export function makeStoredShellSnapshotEncoder( options: { readonly yieldToHost?: Effect.Effect } = {},