Repository navigation
fix(client): a thread whose subscription dies on a defect resubscribes instead of staying blank #25919
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
fix(client): a thread whose subscription dies on a defect resubscribes instead of staying blank #25919
Changes from all commits
6e353ff
16e2e5c
63bb057
a7c8c09
8c05f2f
f98725b
3996498
b832332
8025073
9f0e2a3
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -32,7 +32,12 @@ import * as ConnectionWakeups from "../connection/wakeups.ts"; | |
| import * as Persistence from "../platform/persistence.ts"; | ||
| import { runCachePersistence } from "./cachePersistence.ts"; | ||
| import * as ManagedRelay from "../relay/managedRelay.ts"; | ||
| import { isAuthorizationFailure, request, subscribeDynamic } from "../rpc/client.ts"; | ||
| import { | ||
| isAuthorizationFailure, | ||
| request, | ||
| subscribeDynamic, | ||
| subscriptionRetryDelay, | ||
| } from "../rpc/client.ts"; | ||
| import { parseThreadKey, threadKey } from "./entities.ts"; | ||
| import { applyOrchestrationV2ProjectionEvent } from "./orchestrationV2Projection.ts"; | ||
| import { THREAD_SNAPSHOT_IDLE_TTL_MS } from "./threadRetention.ts"; | ||
|
|
@@ -55,6 +60,8 @@ import { | |
| type EnvironmentThreadStatus, | ||
| } from "./threadState.ts"; | ||
|
|
||
| const THREAD_SUBSCRIPTION_RETRY_DELAY = "250 millis"; | ||
|
|
||
| function statusWithoutLiveData( | ||
| data: Option.Option<OrchestrationV2ThreadProjection>, | ||
| ): EnvironmentThreadStatus { | ||
|
|
@@ -967,8 +974,8 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make | |
| (yield* Ref.get(socketOnlyOpen)) && Option.isNone((yield* SubscriptionRef.get(state)).data) | ||
| ); | ||
| }); | ||
| // A defect parks the subscription, so a socket-only open resubscribes once. | ||
| const httpRetries = yield* Queue.unbounded<void>(); | ||
| // A stream defect parks the subscription until something resubscribes it. | ||
| const resubscribes = yield* Queue.unbounded<void>(); | ||
| const markSynchronizing = Effect.gen(function* () { | ||
| if (yield* Ref.get(resumingLive)) return; | ||
| yield* SubscriptionRef.update(state, (current) => | ||
|
|
@@ -979,15 +986,27 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make | |
| }); | ||
|
|
||
| yield* markSynchronizing; | ||
| yield* Effect.forkScoped( | ||
| // A 404 can arrive before the thread exists, so a miss asks HTTP again while mounted. | ||
| let httpMisses = 0; | ||
| let defectRetries = 0; | ||
| const backOffAfterDefect = Effect.suspend(() => { | ||
| const retryDelay = subscriptionRetryDelay(THREAD_SUBSCRIPTION_RETRY_DELAY, defectRetries); | ||
| defectRetries += 1; | ||
| return setStreamError("Could not synchronize the thread.").pipe( | ||
| Effect.andThen(Effect.sleep(retryDelay)), | ||
| ); | ||
| }); | ||
| const followThread = (): Effect.Effect< | ||
| void, | ||
| never, | ||
| EnvironmentSupervisor.EnvironmentSupervisor | ||
| > => | ||
| subscribeDynamic( | ||
| ORCHESTRATION_V2_WS_METHODS.subscribeThread, | ||
| Effect.fn("EnvironmentThreadState.makeSubscribeInput")(function* (session) { | ||
| let current = yield* SubscriptionRef.get(state); | ||
| // A prior definitive miss (or delete event) already cleared this thread. | ||
| // Park the subscription attempt without opening the socket so we do not | ||
| // retry forever against a known-missing id. | ||
| if (current.status === "deleted") { | ||
| // A delete event already cleared this thread. Park without opening the socket. | ||
| if (current.status === "deleted" && httpMisses === 0) { | ||
| return yield* Effect.never; | ||
| } | ||
|
|
||
|
|
@@ -1024,6 +1043,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make | |
| yield* snapshotLoader.load(prepared, threadId); | ||
| switch (httpResult._tag) { | ||
| case "present": { | ||
| httpMisses = 0; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| if (canLoadHistory && httpResult.history !== undefined) { | ||
| yield* Ref.set(acceptsBoundedSocketSnapshots, true); | ||
| } | ||
|
|
@@ -1052,9 +1072,13 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make | |
| break; | ||
| } | ||
| case "missing": { | ||
| yield* setDeleted(); | ||
| if (current.status !== "deleted") yield* setDeleted(); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do not report a retryable miss as deleted. For an unlisted thread,
|
||
| // A 404 also means "not created yet". Only a delete event may outlive this mount. | ||
| if (resumeCache?.owner === owner) resumeCache.snapshot = undefined; | ||
| yield* Effect.sleep( | ||
| subscriptionRetryDelay(THREAD_SUBSCRIPTION_RETRY_DELAY, httpMisses++), | ||
| ); | ||
| yield* Queue.offer(resubscribes, undefined); | ||
| return yield* Effect.never; | ||
| } | ||
| case "unavailable": { | ||
|
|
@@ -1085,22 +1109,49 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make | |
| { | ||
| // A socket-only open that fails retries over HTTP, which tells a | ||
| // missing thread apart from a failure, before showing an error. | ||
| onDefect: Effect.fn("EnvironmentThreadState.onDefect")(function* () { | ||
| onDefect: Effect.fn("EnvironmentThreadState.onDefect")(function* (cause) { | ||
| if (yield* failedSocketOnlyOpen) { | ||
| yield* Ref.set(socketOnlyOpen, false); | ||
| return yield* Queue.offer(httpRetries, undefined); | ||
| return yield* Queue.offer(resubscribes, undefined); | ||
| } | ||
| if (!Cause.hasDies(cause)) { | ||
| return yield* setStreamError("Could not synchronize the thread."); | ||
| } | ||
| yield* setStreamError("Could not synchronize the thread."); | ||
| yield* backOffAfterDefect; | ||
| yield* Queue.offer(resubscribes, undefined); | ||
| }), | ||
| onExpectedFailure: Effect.fn("EnvironmentThreadState.onExpectedFailure")(function* (cause) { | ||
| const retryOverHttp = !isAuthorizationFailure(cause) && (yield* failedSocketOnlyOpen); | ||
| if (!retryOverHttp) yield* setStreamError(formatThreadError(cause)); | ||
| }), | ||
| retryExpectedFailureAfter: "250 millis", | ||
| resubscribe: Stream.merge(foregroundResubscriptions, Stream.fromQueue(httpRetries)), | ||
| retryExpectedFailureAfter: THREAD_SUBSCRIPTION_RETRY_DELAY, | ||
| resubscribe: Stream.merge(foregroundResubscriptions, Stream.fromQueue(resubscribes)), | ||
| }, | ||
| ).pipe(Stream.runForEachArray(applyItems)), | ||
| ); | ||
| ).pipe( | ||
| Stream.runForEachArray((items) => | ||
| applyItems(items).pipe( | ||
| Effect.andThen( | ||
| Effect.sync(() => { | ||
| defectRetries = 0; | ||
| }), | ||
| ), | ||
| ), | ||
| ), | ||
| // A defect while applying items ends the stream; resubscribe for a fresh snapshot. | ||
| Effect.catchCause((cause) => | ||
| Cause.hasInterrupts(cause) | ||
| ? Effect.interrupt | ||
| : Effect.logWarning("Thread subscription failed; resubscribing.", { | ||
| cause: Cause.pretty(cause), | ||
| environmentId, | ||
| threadId, | ||
| }).pipe( | ||
| Effect.andThen(backOffAfterDefect), | ||
| Effect.andThen(Effect.suspend(followThread)), | ||
| ), | ||
| ), | ||
| ); | ||
| yield* Effect.forkScoped(followThread()); | ||
|
|
||
| return state; | ||
| }); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Script executed:
sed -n '940,1160p' packages/client-runtime/src/state/threads.tsRepository: pingdotgg/t3code
Length of output: 9152
Script executed:
Repository: pingdotgg/t3code
Length of output: 14706
Script executed:
Repository: pingdotgg/t3code
Length of output: 19443
Script executed:
Repository: pingdotgg/t3code
Length of output: 14068
Script executed:
Repository: pingdotgg/t3code
Length of output: 29050
Script executed:
Repository: pingdotgg/t3code
Length of output: 8385
Clear the error after synchronization recovers.
If HTTP returns
missing, thenunavailable, a socket defect can set an error while the state is deleted. A later HTTP snapshot preserves the error, and the completion marker cannot clear it. Without a changed event or another resubscription, the thread stays cached and can continue to show “Syncing messages...”. Clear the error when synchronization is confirmed.