| author | |
| committer | |
| log | aff9318bf46beaf05cc7155b428d3f0b8711efd2 |
| tree | e6c97ee915fb37744a49afb6d030115a7baff2ca |
| parent | b5a0f810108d42ca8635b5a3d75a6e885bb3a254 |
| signature | Signed by PGP key B5690EEEBB952194 |
3 files changed, 145 insertions(+), 2 deletions(-)
apps/mobile/src/lib/composerAttachmentUploadQueue.test.ts+25| ... | ... | @@ -105,6 +105,31 @@ describe("composer attachment upload queue", () => { |
| 105 | 105 | queue.dispose(); |
| 106 | 106 | }); |
| 107 | 107 | |
| 108 | it("retries a failed attachment when the worker restores requests after reconnect", async () => { | |
| 109 | let states: Readonly<Record<string, ComposerAttachmentUploadState>> = {}; | |
| 110 | const upload = vi.fn().mockRejectedValueOnce(new Error("Disconnected")).mockResolvedValue(true); | |
| 111 | const queue = createComposerAttachmentUploadQueue({ | |
| 112 | upload, | |
| 113 | onChange: (next) => { | |
| 114 | states = next; | |
| 115 | }, | |
| 116 | }); | |
| 117 | const local = request("failed-upload"); | |
| 118 | const key = composerAttachmentUploadKey(environmentId, local.attachment.id); | |
| 119 | queue.sync([local]); | |
| 120 | await queue.settled(); | |
| 121 | expect(states[key]?.status).toBe("failed"); | |
| 122 | queue.sync([local]); | |
| 123 | await queue.settled(); | |
| 124 | expect(upload).toHaveBeenCalledTimes(1); | |
| 125 | queue.sync([]); | |
| 126 | queue.sync([local]); | |
| 127 | await queue.settled(); | |
| 128 | expect(upload).toHaveBeenCalledTimes(2); | |
| 129 | expect(states[key]?.status).toBe("ready"); | |
| 130 | queue.dispose(); | |
| 131 | }); | |
| 132 | ||
| 108 | 133 | it("ignores a late completion after removal or environment switch", async () => { |
| 109 | 134 | const gate = Promise.withResolvers<boolean>(); |
| 110 | 135 | const started = Promise.withResolvers<void>(); |
apps/web/src/lib/attachmentUploadQueue.test.ts+84-1| ... | ... | @@ -1,5 +1,7 @@ |
| 1 | 1 | import { EnvironmentId } from "@t3tools/contracts"; |
| 2 | 2 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| 3 | import { Atom, AsyncResult } from "effect/unstable/reactivity"; | |
| 4 | import { appAtomRegistry } from "../rpc/atomRegistry"; | |
| 3 | 5 | |
| 4 | 6 | import { |
| 5 | 7 | composerFileNeedsReattach, |
| ... | ... | @@ -10,6 +12,7 @@ import { |
| 10 | 12 | } from "../composerDraftStore"; |
| 11 | 13 | |
| 12 | 14 | const mocks = vi.hoisted(() => ({ |
| 15 | connectionStateAtom: vi.fn(), | |
| 13 | 16 | createAssetUrl: vi.fn(), |
| 14 | 17 | createUploadUrl: Symbol("create-upload-url"), |
| 15 | 18 | executeAtomQuery: vi.fn(), |
| ... | ... | @@ -24,7 +27,14 @@ vi.mock("@t3tools/client-runtime/state/runtime", () => ({ |
| 24 | 27 | squashAtomCommandFailure: (result: { readonly error: unknown }) => result.error, |
| 25 | 28 | })); |
| 26 | 29 | |
| 27 | vi.mock("../rpc/atomRegistry", () => ({ appAtomRegistry: {} })); | |
| 30 | vi.mock("../rpc/atomRegistry", async () => { | |
| 31 | const { AtomRegistry } = await import("effect/unstable/reactivity"); | |
| 32 | return { appAtomRegistry: AtomRegistry.make() }; | |
| 33 | }); | |
| 34 | ||
| 35 | vi.mock("../connection/catalog", () => ({ | |
| 36 | environmentCatalog: { stateAtom: mocks.connectionStateAtom }, | |
| 37 | })); | |
| 28 | 38 | |
| 29 | 39 | vi.mock("../state/assets", () => ({ |
| 30 | 40 | assetEnvironment: { createUrl: mocks.createAssetUrl }, |
| ... | ... | @@ -140,8 +150,22 @@ function makeFile(id: string): ComposerFileAttachment { |
| 140 | 150 | }; |
| 141 | 151 | } |
| 142 | 152 | |
| 153 | const connectionStates = Atom.family((_environmentId: EnvironmentId) => | |
| 154 | Atom.make(AsyncResult.success({ phase: "connected" })), | |
| 155 | ); | |
| 156 | ||
| 157 | function setConnected(environmentId: EnvironmentId, connected: boolean) { | |
| 158 | appAtomRegistry.set( | |
| 159 | connectionStates(environmentId), | |
| 160 | AsyncResult.success({ phase: connected ? "connected" : "backoff" }), | |
| 161 | ); | |
| 162 | } | |
| 163 | ||
| 143 | 164 | describe("attachmentUploadQueue", () => { |
| 144 | 165 | beforeEach(() => { |
| 166 | mocks.connectionStateAtom.mockImplementation(connectionStates); | |
| 167 | setConnected(firstEnvironment, true); | |
| 168 | setConnected(secondEnvironment, true); | |
| 145 | 169 | TestXmlHttpRequest.requests = []; |
| 146 | 170 | mocks.createAssetUrl.mockReset(); |
| 147 | 171 | mocks.createAssetUrl.mockImplementation((target: unknown) => target); |
| ... | ... | @@ -183,6 +207,65 @@ describe("attachmentUploadQueue", () => { |
| 183 | 207 | vi.unstubAllGlobals(); |
| 184 | 208 | }); |
| 185 | 209 | |
| 210 | it.each([false, true])( | |
| 211 | "retries a failed file once after reconnect, including a late HTTP failure: %s", | |
| 212 | async (lateFailure) => { | |
| 213 | const image = makeFile("reconnect"); | |
| 214 | startAttachmentUpload({ environmentId: firstEnvironment, image }); | |
| 215 | await Promise.resolve(); | |
| 216 | const firstSettled = awaitAttachmentUploads([image.id]); | |
| 217 | setConnected(firstEnvironment, false); | |
| 218 | if (lateFailure) setConnected(firstEnvironment, true); | |
| 219 | TestXmlHttpRequest.requests[0]!.complete(503); | |
| 220 | await firstSettled; | |
| 221 | if (!lateFailure) setConnected(firstEnvironment, true); | |
| 222 | await Promise.resolve(); | |
| 223 | await Promise.resolve(); | |
| 224 | expect(TestXmlHttpRequest.requests).toHaveLength(2); | |
| 225 | const retrySettled = awaitAttachmentUploads([image.id]); | |
| 226 | TestXmlHttpRequest.requests[1]!.complete(503); | |
| 227 | await retrySettled; | |
| 228 | setConnected(firstEnvironment, true); | |
| 229 | await Promise.resolve(); | |
| 230 | expect(TestXmlHttpRequest.requests).toHaveLength(2); | |
| 231 | expect(readAttachmentUpload(image.id)?.status).toBe("failed"); | |
| 232 | ||
| 233 | setConnected(firstEnvironment, false); | |
| 234 | setConnected(firstEnvironment, true); | |
| 235 | await Promise.resolve(); | |
| 236 | await Promise.resolve(); | |
| 237 | const finalSettled = awaitAttachmentUploads([image.id]); | |
| 238 | TestXmlHttpRequest.requests[2]!.complete(); | |
| 239 | await finalSettled; | |
| 240 | expect( | |
| 241 | getUploadedAttachments({ environmentId: firstEnvironment, images: [image] }), | |
| 242 | ).not.toBeNull(); | |
| 243 | setConnected(firstEnvironment, false); | |
| 244 | setConnected(firstEnvironment, true); | |
| 245 | await Promise.resolve(); | |
| 246 | expect(TestXmlHttpRequest.requests).toHaveLength(3); | |
| 247 | }, | |
| 248 | ); | |
| 249 | ||
| 250 | it("does not retry for another environment or after the attachment is removed", async () => { | |
| 251 | const image = makeFile("removed"); | |
| 252 | startAttachmentUpload({ environmentId: firstEnvironment, image }); | |
| 253 | await Promise.resolve(); | |
| 254 | const settled = awaitAttachmentUploads([image.id]); | |
| 255 | TestXmlHttpRequest.requests[0]!.complete(503); | |
| 256 | await settled; | |
| 257 | setConnected(secondEnvironment, false); | |
| 258 | setConnected(secondEnvironment, true); | |
| 259 | await Promise.resolve(); | |
| 260 | expect(TestXmlHttpRequest.requests).toHaveLength(1); | |
| 261 | setConnected(firstEnvironment, false); | |
| 262 | setConnected(firstEnvironment, true); | |
| 263 | releaseAttachmentUpload(image.id); | |
| 264 | await Promise.resolve(); | |
| 265 | expect(TestXmlHttpRequest.requests).toHaveLength(1); | |
| 266 | expect(readAttachmentUpload(image.id)).toBeUndefined(); | |
| 267 | }); | |
| 268 | ||
| 186 | 269 | it("uploads images immediately and sends attachment references", async () => { |
| 187 | 270 | const image = { |
| 188 | 271 | ...makeImage("image-1"), |
apps/web/src/lib/attachmentUploadQueue.ts+36-1| ... | ... | @@ -12,6 +12,8 @@ import { |
| 12 | 12 | type PersistedAttachmentVerification, |
| 13 | 13 | } from "@t3tools/client-runtime/state/attachments"; |
| 14 | 14 | import { create } from "zustand"; |
| 15 | import * as Option from "effect/Option"; | |
| 16 | import { AsyncResult } from "effect/unstable/reactivity"; | |
| 15 | 17 | |
| 16 | 18 | import { |
| 17 | 19 | DraftId, |
| ... | ... | @@ -21,6 +23,7 @@ import { |
| 21 | 23 | type ComposerThreadTarget, |
| 22 | 24 | } from "../composerDraftStore"; |
| 23 | 25 | import { appAtomRegistry } from "../rpc/atomRegistry"; |
| 26 | import { environmentCatalog } from "../connection/catalog"; | |
| 24 | 27 | import { assetEnvironment } from "../state/assets"; |
| 25 | 28 | import { attachmentEnvironment } from "../state/attachments"; |
| 26 | 29 | import { readPreparedConnection } from "../state/session"; |
| ... | ... | @@ -58,8 +61,10 @@ interface UploadJob { |
| 58 | 61 | attachmentId: string | null; |
| 59 | 62 | cancelled: boolean; |
| 60 | 63 | abort: (() => void) | null; |
| 64 | stopWatchingConnection: () => void; | |
| 61 | 65 | } |
| 62 | 66 | |
| 67 | // Failed jobs retain their source and connection subscription until retry or release. | |
| 63 | 68 | const jobsByImageId = new Map<string, UploadJob>(); |
| 64 | 69 | const queue: UploadJob[] = []; |
| 65 | 70 | const activeUploadsByEnvironment = new Map<EnvironmentId, number>(); |
| ... | ... | @@ -358,7 +363,11 @@ function pumpUploads(): void { |
| 358 | 363 | } |
| 359 | 364 | }) |
| 360 | 365 | .finally(() => { |
| 361 | if (jobsByImageId.get(job.image.id) === job) { | |
| 366 | if ( | |
| 367 | jobsByImageId.get(job.image.id) === job && | |
| 368 | readAttachmentUpload(job.image.id)?.status !== "failed" | |
| 369 | ) { | |
| 370 | job.stopWatchingConnection(); | |
| 362 | 371 | jobsByImageId.delete(job.image.id); |
| 363 | 372 | } |
| 364 | 373 | const remaining = (activeUploadsByEnvironment.get(job.environmentId) ?? 1) - 1; |
| ... | ... | @@ -427,9 +436,34 @@ export function startAttachmentUpload(input: { |
| 427 | 436 | attachmentId: null, |
| 428 | 437 | cancelled: false, |
| 429 | 438 | abort: null, |
| 439 | stopWatchingConnection: () => {}, | |
| 430 | 440 | }; |
| 431 | 441 | |
| 432 | 442 | jobsByImageId.set(input.image.id, job); |
| 443 | const connectionAtom = environmentCatalog.stateAtom(job.environmentId); | |
| 444 | const isConnected = () => | |
| 445 | Option.exists( | |
| 446 | AsyncResult.value(appAtomRegistry.get(connectionAtom)), | |
| 447 | (state) => state.phase === "connected", | |
| 448 | ); | |
| 449 | let wasConnected = isConnected(); | |
| 450 | job.stopWatchingConnection = appAtomRegistry.subscribe(connectionAtom, () => { | |
| 451 | const connected = isConnected(); | |
| 452 | const reconnected = connected && !wasConnected; | |
| 453 | wasConnected = connected; | |
| 454 | if (!reconnected) return; | |
| 455 | // The HTTP failure can arrive after the socket has already reconnected. | |
| 456 | // Wait for that attempt, then retry only if this job still owns the file. | |
| 457 | void job.settled.then(() => { | |
| 458 | if ( | |
| 459 | jobsByImageId.get(job.image.id) === job && | |
| 460 | readAttachmentUpload(job.image.id)?.status === "failed" && | |
| 461 | isConnected() | |
| 462 | ) { | |
| 463 | retryAttachmentUpload(input); | |
| 464 | } | |
| 465 | }); | |
| 466 | }); | |
| 433 | 467 | queue.push(job); |
| 434 | 468 | setUploadState(input.image.id, { |
| 435 | 469 | status: "uploading", |
| ... | ... | @@ -451,6 +485,7 @@ function cancelAttachmentUpload(imageId: string): void { |
| 451 | 485 | return; |
| 452 | 486 | } |
| 453 | 487 | job.cancelled = true; |
| 488 | job.stopWatchingConnection(); | |
| 454 | 489 | jobsByImageId.delete(imageId); |
| 455 | 490 | const queuedIndex = queue.indexOf(job); |
| 456 | 491 | if (queuedIndex !== -1) { |