authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-05 17:04:24-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-06 00:04:24+00:00
logee6991ad502c7c8979ab47b6a8fc645498f5b4ce
treea803603f5d8fda20b429818d2e282e495326ab28
parent7a047d0136de606accc8270dce64f2260529c120
signature Signed by PGP key B5690EEEBB952194

fix(server): a T3 Connect preferences save finishes even if the client disconnects (#16266)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

3 files changed, 36 insertions(+), 16 deletions(-)

apps/server/src/cloud/CloudPreferences.test.ts+29
......@@ -217,3 +217,32 @@ it.effect("changes nothing when the current hold setting can't be read", () =>
217217 }),
218218 ),
219219);
220
221it.effect("an update the client walks away from still finishes", () => {
222 let release!: () => void;
223 let started!: () => void;
224 const released = new Promise<void>((resolve) => {
225 release = resolve;
226 });
227 const atRelay = new Promise<void>((resolve) => {
228 started = resolve;
229 });
230 return withService({ holdFirstRelayCall: { started, released } }, ({ preferences, stored }) =>
231 Effect.gen(function* () {
232 const update = yield* preferences
233 .update({ publishAgentActivity: true, holdWebhooksWhileOffline: true })
234 .pipe(Effect.forkChild);
235 yield* Effect.promise(() => atRelay);
236 // The interrupt is in flight before the relay answers.
237 const interrupted = yield* Fiber.interrupt(update).pipe(Effect.forkChild);
238 yield* Effect.yieldNow;
239 release();
240 yield* Fiber.join(interrupted);
241 assert.equal(new TextDecoder().decode(stored.get(PUBLISH_AGENT_ACTIVITY_SECRET)), "true");
242 assert.equal(
243 new TextDecoder().decode(stored.get(HOLD_WEBHOOKS_WHILE_OFFLINE_SECRET)),
244 "true",
245 );
246 }),
247 );
248});
apps/server/src/cloud/CloudPreferences.ts+3
......@@ -121,6 +121,9 @@ const make = Effect.gen(function* () {
121121 }
122122 yield* awarenessRelay.requestCatchUp();
123123 },
124 // A client that disconnects mid-update must not stop it between a save and
125 // its rollback, so an update always finishes or undoes itself.
126 Effect.uninterruptible,
124127 updateLock.withPermits(1),
125128 );
126129
apps/server/src/secrets/SecretRequests.ts+4-16
......@@ -105,11 +105,7 @@ const make = Effect.gen(function* () {
105105 turnItemTypes: ["secret_request"],
106106 messageRoles: [],
107107 })
108 .pipe(
109 Effect.mapError(
110 (cause) => new SecretRequestError({ reason: "load_failed", cause: cause }),
111 ),
112 );
108 .pipe(Effect.mapError((cause) => new SecretRequestError({ reason: "load_failed", cause })));
113109 const item = records.turnItems.find((candidate) => candidate.id === input.turnItemId);
114110 if (item?.type !== "secret_request" || item.runId === null || item.nodeId === null) {
115111 return yield* new SecretRequestError({ reason: "not_found" });
......@@ -160,9 +156,7 @@ const make = Effect.gen(function* () {
160156 secretStatus,
161157 })
162158 .pipe(
163 Effect.mapError(
164 (cause) => new SecretRequestError({ reason: "record_failed", cause: cause }),
165 ),
159 Effect.mapError((cause) => new SecretRequestError({ reason: "record_failed", cause })),
166160 // The card still says pending, so the user can save again; the value
167161 // stored above would make that retry look already answered.
168162 Effect.tapError(() =>
......@@ -181,9 +175,7 @@ const make = Effect.gen(function* () {
181175 messageRoles: [],
182176 })
183177 .pipe(
184 Effect.mapError(
185 (cause) => new SecretRequestError({ reason: "record_failed", cause: cause }),
186 ),
178 Effect.mapError((cause) => new SecretRequestError({ reason: "record_failed", cause })),
187179 );
188180 const card = recorded.turnItems.find((candidate) => candidate.id === item.id);
189181 if (card?.type === "secret_request" && card.secretStatus !== "saved") {
......@@ -229,11 +221,7 @@ const make = Effect.gen(function* () {
229221 return yield* new SecretRequestError({ reason: "invalid_ref" });
230222 const stored = yield* store
231223 .get(storeName(input.ref))
232 .pipe(
233 Effect.mapError(
234 (cause) => new SecretRequestError({ reason: "read_failed", cause: cause }),
235 ),
236 );
224 .pipe(Effect.mapError((cause) => new SecretRequestError({ reason: "read_failed", cause })));
237225 const decoded = Option.flatMap(stored, (bytes) =>
238226 decodeStored(new TextDecoder().decode(bytes)),
239227 );