authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-08-14 10:46:33+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-14 10:46:33+02:00
log21a3669cee0c6fb54f5afe065cf0eb0348bd232c
tree441a6f11119ba383ba533ea1477ce8f66f09234d
parentb3b4b57794476c2e130d38f9243f3c5da1a0cbe7
signature Signed by PGP key B5690EEEBB952194

fix(mobile): prevent OTA update restart crashes (#6324)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

13 files changed, 959 insertions(+), 34 deletions(-)

apps/mobile/src/features/home/HomeRouteScreen.tsx+2-1
...@@ -11,7 +11,7 @@ import { useSavedRemoteConnections } from "../../state/use-remote-environment-re...@@ -11,7 +11,7 @@ import { useSavedRemoteConnections } from "../../state/use-remote-environment-re
11import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout";11import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout";
12import { WorkspaceEmptyDetail } from "../layout/WorkspaceEmptyDetail";12import { WorkspaceEmptyDetail } from "../layout/WorkspaceEmptyDetail";
13import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar";13import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar";
14import { checkForAppUpdateOnLaunch } from "../updates/app-updates";14import { checkForAppUpdateOnLaunch, startAppUpdateForegroundRecheck } from "../updates/app-updates";
15import { AndroidHomeFabLayout } from "./AndroidHomeFab";15import { AndroidHomeFabLayout } from "./AndroidHomeFab";
16import { HomeScreen } from "./HomeScreen";16import { HomeScreen } from "./HomeScreen";
17import { HomeHeader } from "./HomeHeader";17import { HomeHeader } from "./HomeHeader";
...@@ -34,6 +34,7 @@ export function HomeRouteScreen() {...@@ -34,6 +34,7 @@ export function HomeRouteScreen() {
3434
35 useEffect(() => {35 useEffect(() => {
36 void checkForAppUpdateOnLaunch();36 void checkForAppUpdateOnLaunch();
37 startAppUpdateForegroundRecheck();
37 }, []);38 }, []);
3839
39 const {40 const {
apps/mobile/src/features/settings/SettingsRouteScreen.tsx+12-5
...@@ -608,7 +608,10 @@ function AppSettingsSection() {...@@ -608,7 +608,10 @@ function AppSettingsSection() {
608 if (updateInFlight.current) return;608 if (updateInFlight.current) return;
609 updateInFlight.current = true;609 updateInFlight.current = true;
610 try {610 try {
611 // The user asked for this restart by tapping the version row, so it may
612 // apply immediately instead of prompting.
611 await runAppUpdateCheck({613 await runAppUpdateCheck({
614 applyMode: "immediate",
612 onFailure: (message) => Alert.alert("Update failed", message),615 onFailure: (message) => Alert.alert("Update failed", message),
613 onStateChange: setUpdateState,616 onStateChange: setUpdateState,
614 });617 });
...@@ -631,11 +634,15 @@ function AppSettingsSection() {...@@ -631,11 +634,15 @@ function AppSettingsSection() {
631 ? "Checking…"634 ? "Checking…"
632 : updateState === "downloading"635 : updateState === "downloading"
633 ? "Downloading…"636 ? "Downloading…"
634 : updateState === "restarting"637 : // "ready" appears only when this check joined an in-flight background-mode
635 ? "Restarting…"638 // check; that download installs at the next backgrounding.
636 : updateState === "current"639 updateState === "ready"
637 ? "Up to date"640 ? "Update ready"
638 : null;641 : updateState === "restarting"
642 ? "Restarting…"
643 : updateState === "current"
644 ? "Up to date"
645 : null;
639646
640 const versionRow = (647 const versionRow = (
641 <View className="flex-row items-center gap-4 p-4">648 <View className="flex-row items-center gap-4 p-4">
apps/mobile/src/features/updates/app-updates.test.ts+371-9
...@@ -1,11 +1,15 @@...@@ -1,11 +1,15 @@
1import { describe, expect, it, vi } from "vite-plus/test";1import { describe, expect, it, vi } from "vite-plus/test";
22
3import {3import {
4 createAppUpdateDeferral,
4 createAppUpdateLaunchCheck,5 createAppUpdateLaunchCheck,
6 FOREGROUND_APP_UPDATE_RECHECK_AFTER_MS,
5 registerHiddenUpdateTap,7 registerHiddenUpdateTap,
6 runAppUpdateCheck,8 runAppUpdateCheck,
9 shouldRecheckAppUpdateOnForeground,
7 type AppUpdateCheckState,10 type AppUpdateCheckState,
8 type AppUpdateClient,11 type AppUpdateClient,
12 type AppUpdateEnvironment,
9} from "./app-updates";13} from "./app-updates";
1014
11vi.mock("expo-updates", () => ({15vi.mock("expo-updates", () => ({
...@@ -31,6 +35,41 @@ function makeUpdateClient(overrides: Partial<AppUpdateClient> = {}): AppUpdateCl...@@ -31,6 +35,41 @@ function makeUpdateClient(overrides: Partial<AppUpdateClient> = {}): AppUpdateCl
31 };35 };
32}36}
3337
38function makeUpdateEnvironment(overrides: Partial<AppUpdateEnvironment> = {}): {
39 readonly backgroundCallbacks: Array<() => void>;
40 readonly environment: AppUpdateEnvironment;
41 readonly foregroundStayCallbacks: Array<() => void>;
42} {
43 const backgroundCallbacks: Array<() => void> = [];
44 const foregroundStayCallbacks: Array<() => void> = [];
45 return {
46 backgroundCallbacks,
47 foregroundStayCallbacks,
48 environment: {
49 confirmInstallNow: vi.fn(async () => true),
50 flushPendingWrites: vi.fn(async () => {}),
51 isSafeToRestartInBackground: vi.fn(async () => true),
52 onNextBackground: vi.fn((apply: () => void, _includeCurrent: boolean) => {
53 backgroundCallbacks.push(apply);
54 }),
55 onForegroundStay: vi.fn((apply: () => void) => {
56 foregroundStayCallbacks.push(apply);
57 }),
58 ...overrides,
59 },
60 };
61}
62
63function makeAvailableUpdateClient(overrides: Partial<AppUpdateClient> = {}): AppUpdateClient {
64 return makeUpdateClient({
65 checkForUpdateAsync: vi.fn(async () => ({
66 isAvailable: true,
67 isRollBackToEmbedded: false,
68 })),
69 ...overrides,
70 });
71}
72
34describe("runAppUpdateCheck", () => {73describe("runAppUpdateCheck", () => {
35 it("does nothing while running from the Metro development server", async () => {74 it("does nothing while running from the Metro development server", async () => {
36 vi.stubGlobal("__DEV__", true);75 vi.stubGlobal("__DEV__", true);
...@@ -45,23 +84,313 @@ describe("runAppUpdateCheck", () => {...@@ -45,23 +84,313 @@ describe("runAppUpdateCheck", () => {
45 expect(client.checkForUpdateAsync).not.toHaveBeenCalled();84 expect(client.checkForUpdateAsync).not.toHaveBeenCalled();
46 });85 });
4786
48 it("downloads and restarts when a new update is available", async () => {87 it("downloads silently and installs at the next backgrounding", async () => {
49 const client = makeUpdateClient({88 const client = makeAvailableUpdateClient();
50 checkForUpdateAsync: vi.fn(async () => ({89 const { backgroundCallbacks, environment } = makeUpdateEnvironment();
51 isAvailable: true,90 const deferral = createAppUpdateDeferral();
52 isRollBackToEmbedded: false,
53 })),
54 });
55 const states: AppUpdateCheckState[] = [];91 const states: AppUpdateCheckState[] = [];
5692
57 await runAppUpdateCheck({ client, onStateChange: (state) => states.push(state) });93 await runAppUpdateCheck({
94 client,
95 deferral,
96 environment,
97 onStateChange: (state) => states.push(state),
98 });
5899
59 expect(client.checkForUpdateAsync).toHaveBeenCalledOnce();100 expect(client.checkForUpdateAsync).toHaveBeenCalledOnce();
60 expect(client.fetchUpdateAsync).toHaveBeenCalledOnce();101 expect(client.fetchUpdateAsync).toHaveBeenCalledOnce();
102 expect(environment.confirmInstallNow).not.toHaveBeenCalled();
103 expect(client.reloadAsync).not.toHaveBeenCalled();
104 expect(states).toEqual(["checking", "downloading", "ready"]);
105 expect(deferral.pendingInstall).toBe(true);
106 expect(backgroundCallbacks).toHaveLength(1);
107
108 backgroundCallbacks[0]!();
109 await vi.waitFor(() => expect(client.reloadAsync).toHaveBeenCalledOnce());
110 expect(environment.flushPendingWrites).toHaveBeenCalled();
111 });
112
113 it("flushes pending writes before restarting", async () => {
114 const client = makeAvailableUpdateClient();
115 const { environment } = makeUpdateEnvironment();
116
117 await runAppUpdateCheck({
118 applyMode: "immediate",
119 client,
120 deferral: createAppUpdateDeferral(),
121 environment,
122 });
123
124 const flushOrder = vi.mocked(environment.flushPendingWrites).mock.invocationCallOrder[0]!;
125 const reloadOrder = vi.mocked(client.reloadAsync).mock.invocationCallOrder[0]!;
126 expect(flushOrder).toBeLessThan(reloadOrder);
127 });
128
129 it("prompts once the app has stayed foregrounded with the download waiting", async () => {
130 const client = makeAvailableUpdateClient();
131 const { environment, foregroundStayCallbacks } = makeUpdateEnvironment();
132 const deferral = createAppUpdateDeferral();
133
134 await runAppUpdateCheck({ client, deferral, environment });
135 expect(environment.confirmInstallNow).not.toHaveBeenCalled();
136 expect(foregroundStayCallbacks).toHaveLength(1);
137
138 foregroundStayCallbacks[0]!();
139 await vi.waitFor(() => expect(client.reloadAsync).toHaveBeenCalledOnce());
140 expect(environment.confirmInstallNow).toHaveBeenCalledOnce();
141 expect(environment.flushPendingWrites).toHaveBeenCalled();
142 });
143
144 it("keeps the background install armed when the foreground prompt is declined", async () => {
145 const client = makeAvailableUpdateClient();
146 const { backgroundCallbacks, environment, foregroundStayCallbacks } = makeUpdateEnvironment({
147 confirmInstallNow: vi.fn(async () => false),
148 });
149 const deferral = createAppUpdateDeferral();
150
151 await runAppUpdateCheck({ client, deferral, environment });
152
153 foregroundStayCallbacks[0]!();
154 await vi.waitFor(() => expect(environment.confirmInstallNow).toHaveBeenCalledOnce());
155 expect(client.reloadAsync).not.toHaveBeenCalled();
156 expect(deferral.pendingInstall).toBe(true);
157
158 backgroundCallbacks[0]!();
159 await vi.waitFor(() => expect(client.reloadAsync).toHaveBeenCalledOnce());
160 });
161
162 it("skips the foreground prompt once the install is no longer pending", async () => {
163 const client = makeAvailableUpdateClient();
164 const { environment, foregroundStayCallbacks } = makeUpdateEnvironment();
165 const deferral = createAppUpdateDeferral();
166
167 await runAppUpdateCheck({ client, deferral, environment });
168
169 // A failed deferred reload resets the deferral before the stay fires.
170 deferral.pendingInstall = false;
171 foregroundStayCallbacks[0]!();
172
173 expect(environment.confirmInstallNow).not.toHaveBeenCalled();
174 expect(client.reloadAsync).not.toHaveBeenCalled();
175 });
176
177 it("re-arms instead of restarting when the app is no longer safely backgrounded", async () => {
178 const client = makeAvailableUpdateClient();
179 const safe = vi.fn(async () => false);
180 const { backgroundCallbacks, environment } = makeUpdateEnvironment({
181 isSafeToRestartInBackground: safe,
182 });
183 const deferral = createAppUpdateDeferral();
184
185 await runAppUpdateCheck({ client, deferral, environment });
186 expect(backgroundCallbacks).toHaveLength(1);
187 // Arming may fire for an already-backgrounded app…
188 expect(vi.mocked(environment.onNextBackground).mock.calls[0]![1]).toBe(true);
189
190 backgroundCallbacks[0]!();
191 await vi.waitFor(() => expect(backgroundCallbacks).toHaveLength(2));
192 expect(client.reloadAsync).not.toHaveBeenCalled();
193 expect(deferral.pendingInstall).toBe(true);
194 // …but a re-arm must wait for a fresh transition, or an unsafe attempt
195 // would retry in a tight loop within the same background session.
196 expect(vi.mocked(environment.onNextBackground).mock.calls[1]![1]).toBe(false);
197
198 safe.mockResolvedValue(true);
199 backgroundCallbacks[1]!();
200 await vi.waitFor(() => expect(client.reloadAsync).toHaveBeenCalledOnce());
201 });
202
203 it("resets the deferral when the deferred restart fails", async () => {
204 const reportError = vi.spyOn(console, "error").mockImplementation(() => {});
205 const client = makeAvailableUpdateClient({
206 reloadAsync: vi.fn(async () => {
207 throw new Error("reload rejected");
208 }),
209 });
210 const { backgroundCallbacks, environment } = makeUpdateEnvironment();
211 const deferral = createAppUpdateDeferral();
212
213 await runAppUpdateCheck({ client, deferral, environment });
214 backgroundCallbacks[0]!();
215
216 await vi.waitFor(() => expect(deferral.pendingInstall).toBe(false));
217 reportError.mockRestore();
218 });
219
220 it("arms the deferred install once across repeated checks", async () => {
221 const client = makeAvailableUpdateClient();
222 const { environment } = makeUpdateEnvironment();
223 const deferral = createAppUpdateDeferral();
224
225 await runAppUpdateCheck({ client, deferral, environment });
226 await runAppUpdateCheck({ client, deferral, environment });
227
228 expect(environment.onNextBackground).toHaveBeenCalledOnce();
229 expect(environment.onForegroundStay).toHaveBeenCalledOnce();
230 });
231
232 it("restarts into an already-downloaded update when the user asks to install", async () => {
233 const client = makeUpdateClient();
234 const { environment } = makeUpdateEnvironment();
235 const deferral = createAppUpdateDeferral();
236 deferral.pendingInstall = true;
237
238 await runAppUpdateCheck({ applyMode: "immediate", client, deferral, environment });
239
240 expect(client.checkForUpdateAsync).not.toHaveBeenCalled();
241 expect(client.reloadAsync).toHaveBeenCalledOnce();
242 });
243
244 it("honors an immediate request that joined an in-flight background check", async () => {
245 let resolveCheck!: (result: {
246 readonly isAvailable: boolean;
247 readonly isRollBackToEmbedded: boolean;
248 }) => void;
249 const checkResult = new Promise<{
250 readonly isAvailable: boolean;
251 readonly isRollBackToEmbedded: boolean;
252 }>((resolve) => {
253 resolveCheck = resolve;
254 });
255 const client = makeUpdateClient({
256 checkForUpdateAsync: vi.fn(() => checkResult),
257 });
258 const { environment } = makeUpdateEnvironment();
259 const deferral = createAppUpdateDeferral();
260
261 const backgroundCheck = runAppUpdateCheck({ client, deferral, environment });
262 const manualCheck = runAppUpdateCheck({
263 applyMode: "immediate",
264 client,
265 deferral,
266 environment,
267 });
268
269 resolveCheck({ isAvailable: true, isRollBackToEmbedded: false });
270 await Promise.all([backgroundCheck, manualCheck]);
271
272 // The coalesced background check deferred the download, but the manual
273 // caller explicitly asked to install, so the restart happens anyway.
274 expect(client.checkForUpdateAsync).toHaveBeenCalledOnce();
275 expect(client.reloadAsync).toHaveBeenCalledOnce();
276 });
277
278 it("runs a single restart when the deferred install races the foreground prompt", async () => {
279 const client = makeAvailableUpdateClient();
280 let releaseFlush!: () => void;
281 const blockedFlush = new Promise<void>((resolve) => {
282 releaseFlush = resolve;
283 });
284 const flushPendingWrites = vi.fn(async (): Promise<void> => {});
285 const { backgroundCallbacks, environment, foregroundStayCallbacks } = makeUpdateEnvironment({
286 flushPendingWrites,
287 });
288 const deferral = createAppUpdateDeferral();
289
290 await runAppUpdateCheck({ client, deferral, environment });
291 flushPendingWrites.mockReturnValue(blockedFlush);
292
293 // The deferred install starts and blocks on its flush; the foreground
294 // prompt firing in that window must not begin a second restart.
295 backgroundCallbacks[0]!();
296 await vi.waitFor(() => expect(flushPendingWrites).toHaveBeenCalledOnce());
297 foregroundStayCallbacks[0]!();
298 await new Promise((resolve) => setTimeout(resolve, 0));
299 expect(environment.confirmInstallNow).not.toHaveBeenCalled();
300
301 releaseFlush();
302 await vi.waitFor(() => expect(client.reloadAsync).toHaveBeenCalledOnce());
303 });
304
305 it("holds the deferred restart and re-arms when the pre-restart flush fails", async () => {
306 const reportError = vi.spyOn(console, "error").mockImplementation(() => {});
307 const client = makeAvailableUpdateClient();
308 const { backgroundCallbacks, environment } = makeUpdateEnvironment({
309 flushPendingWrites: vi.fn(async () => {
310 throw new Error("disk full");
311 }),
312 });
313 const deferral = createAppUpdateDeferral();
314
315 await runAppUpdateCheck({ client, deferral, environment });
316 backgroundCallbacks[0]!();
317
318 await vi.waitFor(() => expect(backgroundCallbacks).toHaveLength(2));
319 expect(client.reloadAsync).not.toHaveBeenCalled();
320 expect(deferral.pendingInstall).toBe(true);
321 reportError.mockRestore();
322 });
323
324 it("restarts without prompting when the caller asked for an immediate install", async () => {
325 const client = makeAvailableUpdateClient();
326 const { environment } = makeUpdateEnvironment();
327 const states: AppUpdateCheckState[] = [];
328
329 await runAppUpdateCheck({
330 applyMode: "immediate",
331 client,
332 deferral: createAppUpdateDeferral(),
333 environment,
334 onStateChange: (state) => states.push(state),
335 });
336
337 expect(environment.confirmInstallNow).not.toHaveBeenCalled();
61 expect(client.reloadAsync).toHaveBeenCalledOnce();338 expect(client.reloadAsync).toHaveBeenCalledOnce();
62 expect(states).toEqual(["checking", "downloading", "restarting"]);339 expect(states).toEqual(["checking", "downloading", "restarting"]);
63 });340 });
64341
342 it("holds an automatic rollback restart when the flush fails and re-arms it", async () => {
343 const reportError = vi.spyOn(console, "error").mockImplementation(() => {});
344 const client = makeUpdateClient({
345 checkForUpdateAsync: vi.fn(async () => ({
346 isAvailable: false,
347 isRollBackToEmbedded: true,
348 })),
349 fetchUpdateAsync: vi.fn(async () => ({
350 isNew: false,
351 isRollBackToEmbedded: true,
352 })),
353 });
354 const flushPendingWrites = vi.fn(async (): Promise<void> => {
355 throw new Error("storage unavailable");
356 });
357 const { backgroundCallbacks, environment } = makeUpdateEnvironment({ flushPendingWrites });
358 const deferral = createAppUpdateDeferral();
359
360 await runAppUpdateCheck({ client, deferral, environment });
361
362 // Nobody asked for this restart, so it must not discard the state it
363 // failed to land; the rollback waits armed for the next backgrounding.
364 expect(client.reloadAsync).not.toHaveBeenCalled();
365 expect(deferral.pendingInstall).toBe(true);
366 expect(backgroundCallbacks).toHaveLength(1);
367
368 flushPendingWrites.mockResolvedValue(undefined);
369 backgroundCallbacks[0]!();
370 await vi.waitFor(() => expect(client.reloadAsync).toHaveBeenCalledOnce());
371 reportError.mockRestore();
372 });
373
374 it("still restarts a user-requested install when the flush fails", async () => {
375 const reportError = vi.spyOn(console, "error").mockImplementation(() => {});
376 const client = makeAvailableUpdateClient();
377 const { environment } = makeUpdateEnvironment({
378 flushPendingWrites: vi.fn(async () => {
379 throw new Error("storage unavailable");
380 }),
381 });
382
383 await runAppUpdateCheck({
384 applyMode: "immediate",
385 client,
386 deferral: createAppUpdateDeferral(),
387 environment,
388 });
389
390 expect(client.reloadAsync).toHaveBeenCalledOnce();
391 reportError.mockRestore();
392 });
393
65 it("restarts into the embedded bundle for a rollback directive", async () => {394 it("restarts into the embedded bundle for a rollback directive", async () => {
66 const client = makeUpdateClient({395 const client = makeUpdateClient({
67 checkForUpdateAsync: vi.fn(async () => ({396 checkForUpdateAsync: vi.fn(async () => ({
...@@ -73,10 +402,13 @@ describe("runAppUpdateCheck", () => {...@@ -73,10 +402,13 @@ describe("runAppUpdateCheck", () => {
73 isRollBackToEmbedded: true,402 isRollBackToEmbedded: true,
74 })),403 })),
75 });404 });
405 const { environment } = makeUpdateEnvironment();
76406
77 await runAppUpdateCheck({ client });407 await runAppUpdateCheck({ client, deferral: createAppUpdateDeferral(), environment });
78408
79 expect(client.fetchUpdateAsync).toHaveBeenCalledOnce();409 expect(client.fetchUpdateAsync).toHaveBeenCalledOnce();
410 // A rollback pulls a broken bundle, so it never waits on the prompt.
411 expect(environment.confirmInstallNow).not.toHaveBeenCalled();
80 expect(client.reloadAsync).toHaveBeenCalledOnce();412 expect(client.reloadAsync).toHaveBeenCalledOnce();
81 });413 });
82414
...@@ -276,6 +608,36 @@ describe("createAppUpdateLaunchCheck", () => {...@@ -276,6 +608,36 @@ describe("createAppUpdateLaunchCheck", () => {
276 });608 });
277});609});
278610
611describe("shouldRecheckAppUpdateOnForeground", () => {
612 it("requires a meaningful background gap", () => {
613 expect(shouldRecheckAppUpdateOnForeground(null, 100_000, false)).toBe(false);
614 expect(
615 shouldRecheckAppUpdateOnForeground(
616 100_000,
617 100_000 + FOREGROUND_APP_UPDATE_RECHECK_AFTER_MS - 1,
618 false,
619 ),
620 ).toBe(false);
621 expect(
622 shouldRecheckAppUpdateOnForeground(
623 100_000,
624 100_000 + FOREGROUND_APP_UPDATE_RECHECK_AFTER_MS,
625 false,
626 ),
627 ).toBe(true);
628 });
629
630 it("stays quiet while a downloaded update waits for its install", () => {
631 expect(
632 shouldRecheckAppUpdateOnForeground(
633 100_000,
634 100_000 + FOREGROUND_APP_UPDATE_RECHECK_AFTER_MS,
635 true,
636 ),
637 ).toBe(false);
638 });
639});
640
279describe("registerHiddenUpdateTap", () => {641describe("registerHiddenUpdateTap", () => {
280 it("unlocks the manual check on the fifth tap", () => {642 it("unlocks the manual check on the fifth tap", () => {
281 let count = 0;643 let count = 0;
apps/mobile/src/features/updates/app-updates.ts+378-1
...@@ -8,7 +8,13 @@ import {...@@ -8,7 +8,13 @@ import {
8 squashAtomCommandFailure,8 squashAtomCommandFailure,
9} from "@t3tools/client-runtime/state/runtime";9} from "@t3tools/client-runtime/state/runtime";
1010
11export type AppUpdateCheckState = "idle" | "checking" | "downloading" | "restarting" | "current";11export type AppUpdateCheckState =
12 | "idle"
13 | "checking"
14 | "downloading"
15 | "ready"
16 | "restarting"
17 | "current";
1218
13export interface AppUpdateClient {19export interface AppUpdateClient {
14 readonly isEnabled: boolean;20 readonly isEnabled: boolean;
...@@ -23,8 +29,70 @@ export interface AppUpdateClient {...@@ -23,8 +29,70 @@ export interface AppUpdateClient {
23 readonly reloadAsync: () => Promise<void>;29 readonly reloadAsync: () => Promise<void>;
24}30}
2531
32/**
33 * The pieces of the app the update flow has to coordinate with before it may
34 * tear down the JavaScript runtime. Injectable so the flow stays unit-testable.
35 */
36export interface AppUpdateEnvironment {
37 /** Asks the user to install the waiting update now; `false` keeps it deferred. */
38 readonly confirmInstallNow: () => Promise<boolean>;
39 /**
40 * Lands persisted state (drafts, outbox) before the restart. Rejects when a
41 * write failed, so a silent restart can hold off instead of dropping the
42 * unsaved in-memory state.
43 */
44 readonly flushPendingWrites: () => Promise<void>;
45 /**
46 * Whether a deferred restart may fire right now: the app must still be
47 * backgrounded (flush latency or an iOS suspend can push the continuation
48 * into the next foreground session) and not merely paused behind an
49 * app-initiated handoff like the Android image picker.
50 */
51 readonly isSafeToRestartInBackground: () => Promise<boolean>;
52 /**
53 * Runs `apply` the next time the app enters the background. With
54 * `includeCurrent`, an app that is already backgrounded fires immediately
55 * (so a backgrounding that raced module load is not missed); without it,
56 * only a future transition fires, so an attempt that already failed in the
57 * current background session cannot retry in a tight loop.
58 */
59 readonly onNextBackground: (apply: () => void, includeCurrent: boolean) => void;
60 /**
61 * Runs `apply` once the app has stayed foregrounded for the whole prompt
62 * window — the signal that a deferred install has had no backgrounding to
63 * ride on.
64 */
65 readonly onForegroundStay: (apply: () => void) => void;
66}
67
68/** Tracks a downloaded update waiting for a safe moment to install. */
69export interface AppUpdateDeferral {
70 pendingInstall: boolean;
71 /**
72 * Claimed by whichever restart sequence (deferred backgrounding, foreground
73 * prompt, manual install) starts first, so racing paths cannot tear down
74 * the runtime twice.
75 */
76 installInProgress: boolean;
77}
78
79export function createAppUpdateDeferral(): AppUpdateDeferral {
80 return { pendingInstall: false, installInProgress: false };
81}
82
83const appUpdateDeferral = createAppUpdateDeferral();
84
26interface AppUpdateCheckOptions {85interface AppUpdateCheckOptions {
86 /**
87 * "background" (default) installs silently at the next backgrounding,
88 * asking only if the app then stays foregrounded so long that the install
89 * never gets its chance. "immediate" restarts as soon as the download
90 * lands — reserved for flows where the user explicitly requested the update.
91 */
92 readonly applyMode?: "background" | "immediate";
27 readonly client?: AppUpdateClient;93 readonly client?: AppUpdateClient;
94 readonly deferral?: AppUpdateDeferral;
95 readonly environment?: AppUpdateEnvironment;
28 readonly onFailure?: (message: string) => void;96 readonly onFailure?: (message: string) => void;
29 readonly onStateChange?: (state: AppUpdateCheckState) => void;97 readonly onStateChange?: (state: AppUpdateCheckState) => void;
30}98}
...@@ -86,6 +154,15 @@ export async function runAppUpdateCheck(options: AppUpdateCheckOptions = {}): Pr...@@ -86,6 +154,15 @@ export async function runAppUpdateCheck(options: AppUpdateCheckOptions = {}): Pr
86154
87 if (appUpdateCheckInFlight) {155 if (appUpdateCheckInFlight) {
88 await observeAppUpdateCheck(appUpdateCheckInFlight, options);156 await observeAppUpdateCheck(appUpdateCheckInFlight, options);
157 // A background-mode check in flight may have deferred the download this
158 // caller explicitly asked to install; honor the explicit request now.
159 if (options.applyMode === "immediate") {
160 const deferral = options.deferral ?? appUpdateDeferral;
161 if (deferral.pendingInstall) {
162 const environment = options.environment ?? defaultAppUpdateEnvironment;
163 await installPendingAppUpdate(client, environment, deferral, options);
164 }
165 }
89 return;166 return;
90 }167 }
91168
...@@ -109,6 +186,9 @@ export async function runAppUpdateCheck(options: AppUpdateCheckOptions = {}): Pr...@@ -109,6 +186,9 @@ export async function runAppUpdateCheck(options: AppUpdateCheckOptions = {}): Pr
109 appUpdateCheckInFlight = inFlight;186 appUpdateCheckInFlight = inFlight;
110187
111 const execution = performAppUpdateCheck(client, {188 const execution = performAppUpdateCheck(client, {
189 applyMode: options.applyMode,
190 deferral: options.deferral,
191 environment: options.environment,
112 onFailure: (message) => {192 onFailure: (message) => {
113 progress.failure = message;193 progress.failure = message;
114 notifyListeners(failureListeners, message);194 notifyListeners(failureListeners, message);
...@@ -175,6 +255,15 @@ async function performAppUpdateCheck(...@@ -175,6 +255,15 @@ async function performAppUpdateCheck(
175 options: AppUpdateCheckOptions,255 options: AppUpdateCheckOptions,
176): Promise<void> {256): Promise<void> {
177 const setState = options.onStateChange ?? (() => {});257 const setState = options.onStateChange ?? (() => {});
258 const environment = options.environment ?? defaultAppUpdateEnvironment;
259 const deferral = options.deferral ?? appUpdateDeferral;
260
261 // The user explicitly asked to install and a previous check has already
262 // downloaded the update; restart into it without another network round trip.
263 if (options.applyMode === "immediate" && deferral.pendingInstall) {
264 await installPendingAppUpdate(client, environment, deferral, options);
265 return;
266 }
178267
179 setState("checking");268 setState("checking");
180 const check = await settlePromise(() => client.checkForUpdateAsync());269 const check = await settlePromise(() => client.checkForUpdateAsync());
...@@ -203,14 +292,252 @@ async function performAppUpdateCheck(...@@ -203,14 +292,252 @@ async function performAppUpdateCheck(
203 return;292 return;
204 }293 }
205294
295 // A rollback directive exists to pull a broken bundle; never hold it
296 // behind a prompt or a deferred install.
297 if (options.applyMode === "immediate" || fetched.value.isRollBackToEmbedded) {
298 const outcome = await installAppUpdate(
299 client,
300 environment,
301 deferral,
302 options,
303 options.applyMode === "immediate",
304 );
305 if (outcome === "flush-failed") {
306 // Only reachable for an automatic rollback: keep the state-bearing
307 // runtime alive and retry like a deferred install. The fetched rollback
308 // still applies at the next cold start regardless.
309 setState("ready");
310 armDeferredAppUpdateInstall(client, environment, deferral);
311 }
312 return;
313 }
314
315 setState("ready");
316 armDeferredAppUpdateInstall(client, environment, deferral);
317}
318
319type AppUpdateInstallOutcome = "installed" | "flush-failed" | "restart-failed";
320
321/**
322 * Restarting mid-session while native surfaces are mounted is the crashiest
323 * moment expo-updates has, so the restart flushes persistence first and, by
324 * default, waits for a backgrounding — where nothing is rendering and the
325 * teardown is invisible. Only a restart the user explicitly asked for may
326 * proceed over a failed flush; an automatic one aborts with "flush-failed"
327 * so unsaved state is never silently discarded.
328 */
329async function installAppUpdate(
330 client: AppUpdateClient,
331 environment: AppUpdateEnvironment,
332 deferral: AppUpdateDeferral,
333 options: AppUpdateCheckOptions,
334 userRequested: boolean,
335): Promise<AppUpdateInstallOutcome> {
336 // A concurrent install sequence already owns the restart.
337 if (deferral.installInProgress) return "installed";
338 deferral.installInProgress = true;
339 const setState = options.onStateChange ?? (() => {});
206 setState("restarting");340 setState("restarting");
341 const flushed = await settlePromise(() => environment.flushPendingWrites());
342 if (flushed._tag === "Failure") {
343 reportUpdateFailure(flushed, "Could not save pending state.", undefined);
344 if (!userRequested) {
345 deferral.installInProgress = false;
346 return "flush-failed";
347 }
348 }
207 const reloaded = await settlePromise(() => client.reloadAsync());349 const reloaded = await settlePromise(() => client.reloadAsync());
208 if (reloaded._tag === "Failure") {350 if (reloaded._tag === "Failure") {
209 reportUpdateFailure(reloaded, "Downloaded, but could not restart the app.", options.onFailure);351 reportUpdateFailure(reloaded, "Downloaded, but could not restart the app.", options.onFailure);
210 setState("idle");352 setState("idle");
353 deferral.installInProgress = false;
354 return "restart-failed";
355 }
356 return "installed";
357}
358
359/** Restarts into an already-downloaded update at the user's request. */
360async function installPendingAppUpdate(
361 client: AppUpdateClient,
362 environment: AppUpdateEnvironment,
363 deferral: AppUpdateDeferral,
364 options: AppUpdateCheckOptions,
365): Promise<void> {
366 const outcome = await installAppUpdate(client, environment, deferral, options, true);
367 if (outcome === "restart-failed") {
368 // Let later checks re-arm the install; the downloaded update still
369 // applies at the next cold start regardless.
370 deferral.pendingInstall = false;
371 }
372}
373
374function armDeferredAppUpdateInstall(
375 client: AppUpdateClient,
376 environment: AppUpdateEnvironment,
377 deferral: AppUpdateDeferral,
378): void {
379 if (deferral.pendingInstall) return;
380 deferral.pendingInstall = true;
381 scheduleDeferredAppUpdateInstall(client, environment, deferral, true);
382 environment.onForegroundStay(() => {
383 void promptDeferredAppUpdateInstall(client, environment, deferral);
384 });
385}
386
387/**
388 * A deferred install normally rides the next backgrounding, but a session that
389 * never leaves the foreground would sit on the download forever. Only then is
390 * the user asked, and declining simply leaves the background install armed.
391 */
392async function promptDeferredAppUpdateInstall(
393 client: AppUpdateClient,
394 environment: AppUpdateEnvironment,
395 deferral: AppUpdateDeferral,
396): Promise<void> {
397 if (!deferral.pendingInstall || deferral.installInProgress) return;
398 const installNow = await settlePromise(() => environment.confirmInstallNow());
399 if (installNow._tag !== "Success" || !installNow.value) return;
400 // A backgrounding while the alert was up may have started the deferred
401 // restart already; the stale accept must not start a second one.
402 if (!deferral.pendingInstall || deferral.installInProgress) return;
403 await installPendingAppUpdate(client, environment, deferral, {});
404}
405
406function scheduleDeferredAppUpdateInstall(
407 client: AppUpdateClient,
408 environment: AppUpdateEnvironment,
409 deferral: AppUpdateDeferral,
410 includeCurrent: boolean,
411): void {
412 environment.onNextBackground(() => {
413 void applyDeferredAppUpdateInstall(client, environment, deferral);
414 }, includeCurrent);
415}
416
417async function applyDeferredAppUpdateInstall(
418 client: AppUpdateClient,
419 environment: AppUpdateEnvironment,
420 deferral: AppUpdateDeferral,
421): Promise<void> {
422 if (!deferral.pendingInstall || deferral.installInProgress) return;
423 deferral.installInProgress = true;
424 const flushed = await settlePromise(() => environment.flushPendingWrites());
425 const safe = await settlePromise(() => environment.isSafeToRestartInBackground());
426 if (flushed._tag === "Failure" || safe._tag !== "Success" || !safe.value) {
427 if (flushed._tag === "Failure") {
428 // Nothing is lost yet: keep the state-bearing runtime alive and retry
429 // the flush at the next backgrounding instead of restarting over it.
430 reportUpdateFailure(flushed, "Could not save pending state.", undefined);
431 }
432 deferral.installInProgress = false;
433 // This attempt already ran in the current background session; retrying
434 // before a fresh transition would just loop over the same failure.
435 scheduleDeferredAppUpdateInstall(client, environment, deferral, false);
436 return;
437 }
438 const reloaded = await settlePromise(() => client.reloadAsync());
439 if (reloaded._tag === "Failure") {
440 reportUpdateFailure(reloaded, "Downloaded, but could not restart the app.", undefined);
441 deferral.installInProgress = false;
442 // Let later checks re-arm the install; the downloaded update still
443 // applies at the next cold start regardless.
444 deferral.pendingInstall = false;
211 }445 }
212}446}
213447
448async function defaultConfirmInstallNow(): Promise<boolean> {
449 const { Alert } = await import("react-native");
450 return new Promise<boolean>((resolve) => {
451 Alert.alert(
452 "Update ready",
453 "A new version has been downloaded and installs automatically the next time you leave the app. Install it now instead?",
454 [
455 { onPress: () => resolve(false), style: "cancel", text: "Later" },
456 { onPress: () => resolve(true), text: "Install Now" },
457 ],
458 { cancelable: true, onDismiss: () => resolve(false) },
459 );
460 });
461}
462
463async function defaultFlushPendingWrites(): Promise<void> {
464 // Attempt every flush before surfacing the first failure, so one broken
465 // store cannot keep the others from landing.
466 const results = await Promise.allSettled([
467 import("../../state/use-composer-drafts").then((drafts) => drafts.flushComposerDrafts()),
468 import("../../state/thread-outbox").then((outbox) => outbox.flushThreadOutbox()),
469 ]);
470 const failed = results.find(
471 (result): result is PromiseRejectedResult => result.status === "rejected",
472 );
473 if (failed) throw failed.reason;
474}
475
476async function defaultIsSafeToRestartInBackground(): Promise<boolean> {
477 const { isForegroundHandoffActive } = await import("../../lib/foreground-handoff");
478 if (isForegroundHandoffActive()) return false;
479 const { AppState } = await import("react-native");
480 return AppState.currentState === "background";
481}
482
483function defaultOnNextBackground(apply: () => void, includeCurrent: boolean): void {
484 void import("react-native").then(({ AppState }) => {
485 const subscription = AppState.addEventListener("change", (state) => {
486 if (state !== "background") return;
487 subscription.remove();
488 apply();
489 });
490 // The app may already have backgrounded while this module was loading;
491 // the listener alone would then wait a whole extra foreground cycle.
492 if (includeCurrent && AppState.currentState === "background") {
493 subscription.remove();
494 apply();
495 }
496 });
497}
498
499/**
500 * How long the app may stay foregrounded with a downloaded update before the
501 * install prompt appears. Long enough that most sessions background naturally
502 * and install silently instead.
503 */
504export const DEFERRED_INSTALL_PROMPT_AFTER_MS = 30 * 60 * 1000;
505
506/**
507 * The window resets on every backgrounding because that is exactly when the
508 * deferred install gets its chance. iOS "inactive" blips (app switcher, a
509 * pulled-down notification shade) leave the timer running.
510 */
511function defaultOnForegroundStay(apply: () => void): void {
512 void import("react-native").then(({ AppState }) => {
513 let timer: ReturnType<typeof setTimeout> | undefined;
514 const arm = () => {
515 timer ??= setTimeout(() => {
516 subscription.remove();
517 apply();
518 }, DEFERRED_INSTALL_PROMPT_AFTER_MS);
519 };
520 const disarm = () => {
521 if (timer === undefined) return;
522 clearTimeout(timer);
523 timer = undefined;
524 };
525 const subscription = AppState.addEventListener("change", (state) => {
526 if (state === "active") arm();
527 else if (state === "background") disarm();
528 });
529 if (AppState.currentState === "active") arm();
530 });
531}
532
533const defaultAppUpdateEnvironment: AppUpdateEnvironment = {
534 confirmInstallNow: defaultConfirmInstallNow,
535 flushPendingWrites: defaultFlushPendingWrites,
536 isSafeToRestartInBackground: defaultIsSafeToRestartInBackground,
537 onNextBackground: defaultOnNextBackground,
538 onForegroundStay: defaultOnForegroundStay,
539};
540
214function reportUpdateFailure(541function reportUpdateFailure(
215 result: AtomCommandResult<unknown, unknown>,542 result: AtomCommandResult<unknown, unknown>,
216 fallback: string,543 fallback: string,
...@@ -243,3 +570,53 @@ export function createAppUpdateLaunchCheck(...@@ -243,3 +570,53 @@ export function createAppUpdateLaunchCheck(
243}570}
244571
245export const checkForAppUpdateOnLaunch = createAppUpdateLaunchCheck();572export const checkForAppUpdateOnLaunch = createAppUpdateLaunchCheck();
573
574/**
575 * The app can stay resident for days, so a launch-only check misses updates
576 * published while it was in memory. Anything shorter reads as noise: brief
577 * app switches should not trigger network checks or an install prompt.
578 */
579export const FOREGROUND_APP_UPDATE_RECHECK_AFTER_MS = 15 * 60 * 1000;
580
581export function shouldRecheckAppUpdateOnForeground(
582 backgroundedAtMs: number | null,
583 activeAtMs: number,
584 pendingInstall: boolean,
585): boolean {
586 if (pendingInstall) return false;
587 return (
588 backgroundedAtMs !== null &&
589 activeAtMs - backgroundedAtMs >= FOREGROUND_APP_UPDATE_RECHECK_AFTER_MS
590 );
591}
592
593export function createAppUpdateForegroundRecheck(
594 client: AppUpdateClient = Updates,
595 deferral: AppUpdateDeferral = appUpdateDeferral,
596): () => void {
597 let started = false;
598
599 return () => {
600 if (started || !isAppUpdateCheckAvailable(client)) return;
601 started = true;
602 void import("react-native").then(({ AppState }) => {
603 let backgroundedAtMs: number | null = null;
604 AppState.addEventListener("change", (state) => {
605 if (state === "background") {
606 backgroundedAtMs = Date.now();
607 return;
608 }
609 if (state !== "active") return;
610 const shouldCheck = shouldRecheckAppUpdateOnForeground(
611 backgroundedAtMs,
612 Date.now(),
613 deferral.pendingInstall,
614 );
615 backgroundedAtMs = null;
616 if (shouldCheck) void runAppUpdateCheck({ client, deferral });
617 });
618 });
619 };
620}
621
622export const startAppUpdateForegroundRecheck = createAppUpdateForegroundRecheck();
apps/mobile/src/lib/atomic-file.ts created+19
...@@ -0,0 +1,19 @@
1import type { File } from "expo-file-system";
2
3let tempFileSequence = 0;
4
5/**
6 * Replaces a file's contents through a sibling temp file and an overwriting
7 * rename, so an interrupted write (app restart, process death) never leaves a
8 * truncated document at the final path. Each write stages through its own
9 * temp file so concurrent writers to the same destination cannot move or
10 * clobber each other's staging file mid-flight.
11 */
12export async function writeFileAtomically(file: File, contents: string): Promise<void> {
13 const { File: FileConstructor } = await import("expo-file-system");
14 tempFileSequence += 1;
15 const temp = new FileConstructor(file.parentDirectory, `${file.name}.${tempFileSequence}.tmp`);
16 temp.create({ intermediates: true, overwrite: true });
17 temp.write(contents);
18 temp.moveSync(file, { overwrite: true });
19}
apps/mobile/src/lib/composerImages.ts+16-7
...@@ -4,6 +4,7 @@ import {...@@ -4,6 +4,7 @@ import {
4 type UploadChatImageAttachment,4 type UploadChatImageAttachment,
5} from "@t3tools/contracts";5} from "@t3tools/contracts";
6import { estimateBase64ByteSize } from "./base64";6import { estimateBase64ByteSize } from "./base64";
7import { beginForegroundHandoff } from "./foreground-handoff";
7import { uuidv4 } from "./uuid";8import { uuidv4 } from "./uuid";
89
9export interface DraftComposerImageAttachment extends UploadChatImageAttachment {10export interface DraftComposerImageAttachment extends UploadChatImageAttachment {
...@@ -65,13 +66,21 @@ export async function pickComposerImages(input: { readonly existingCount: number...@@ -65,13 +66,21 @@ export async function pickComposerImages(input: { readonly existingCount: number
65 };66 };
66 }67 }
6768
68 const result = await imagePicker.launchImageLibraryAsync({69 // The picker covers the Android activity, which reports the app as
69 mediaTypes: ["images"],70 // backgrounded; the guard keeps background-triggered restarts away mid-pick.
70 allowsMultipleSelection: true,71 const endHandoff = beginForegroundHandoff();
71 selectionLimit: remainingSlots,72 let result: Awaited<ReturnType<typeof imagePicker.launchImageLibraryAsync>>;
72 base64: true,73 try {
73 quality: 1,74 result = await imagePicker.launchImageLibraryAsync({
74 });75 mediaTypes: ["images"],
76 allowsMultipleSelection: true,
77 selectionLimit: remainingSlots,
78 base64: true,
79 quality: 1,
80 });
81 } finally {
82 endHandoff();
83 }
7584
76 if (result.canceled) {85 if (result.canceled) {
77 return {86 return {
apps/mobile/src/lib/foreground-handoff.test.ts created+32
...@@ -0,0 +1,32 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { beginForegroundHandoff, isForegroundHandoffActive } from "./foreground-handoff";
4
5describe("foreground handoff", () => {
6 it("is active only while a handoff is open", () => {
7 expect(isForegroundHandoffActive()).toBe(false);
8 const end = beginForegroundHandoff();
9 expect(isForegroundHandoffActive()).toBe(true);
10 end();
11 expect(isForegroundHandoffActive()).toBe(false);
12 });
13
14 it("stays active until every overlapping handoff ends", () => {
15 const endFirst = beginForegroundHandoff();
16 const endSecond = beginForegroundHandoff();
17 endFirst();
18 expect(isForegroundHandoffActive()).toBe(true);
19 endSecond();
20 expect(isForegroundHandoffActive()).toBe(false);
21 });
22
23 it("tolerates an end function called twice", () => {
24 const endFirst = beginForegroundHandoff();
25 const endSecond = beginForegroundHandoff();
26 endFirst();
27 endFirst();
28 expect(isForegroundHandoffActive()).toBe(true);
29 endSecond();
30 expect(isForegroundHandoffActive()).toBe(false);
31 });
32});
apps/mobile/src/lib/foreground-handoff.ts created+22
...@@ -0,0 +1,22 @@
1/**
2 * Tracks app-initiated OS-surface handoffs (image picker, auth tab, share
3 * sheet). Android reports the app as backgrounded while one of these covers
4 * the activity, so background-triggered work — like a deferred app update
5 * restart — has to wait them out instead of tearing down the mid-flow runtime.
6 */
7let activeHandoffs = 0;
8
9/** Returns an idempotent end function; call it when the handoff resolves. */
10export function beginForegroundHandoff(): () => void {
11 activeHandoffs += 1;
12 let ended = false;
13 return () => {
14 if (ended) return;
15 ended = true;
16 activeHandoffs -= 1;
17 };
18}
19
20export function isForegroundHandoffActive(): boolean {
21 return activeHandoffs > 0;
22}
apps/mobile/src/state/thread-outbox-storage.ts+25-5
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import { EnvironmentId, MessageId, ThreadId } from "@t3tools/contracts";1import { EnvironmentId, MessageId, ThreadId } from "@t3tools/contracts";
2import * as Schema from "effect/Schema";2import * as Schema from "effect/Schema";
33
4import { writeFileAtomically } from "../lib/atomic-file";
4import {5import {
5 decodeQueuedThreadMessage,6 decodeQueuedThreadMessage,
6 encodeQueuedThreadMessage,7 encodeQueuedThreadMessage,
...@@ -9,6 +10,24 @@ import {...@@ -9,6 +10,24 @@ import {
910
10const THREAD_OUTBOX_DIRECTORY = "thread-outbox";11const THREAD_OUTBOX_DIRECTORY = "thread-outbox";
1112
13const inFlightWrites = new Set<Promise<void>>();
14
15function trackInFlightWrite(operation: Promise<void>): Promise<void> {
16 inFlightWrites.add(operation);
17 void operation.catch(() => undefined).finally(() => inFlightWrites.delete(operation));
18 return operation;
19}
20
21/**
22 * Awaits queued-message writes so an app update restart cannot tear down the
23 * runtime while one is mid-file.
24 */
25export async function flushThreadOutboxWrites(): Promise<void> {
26 while (inFlightWrites.size > 0) {
27 await Promise.allSettled(inFlightWrites);
28 }
29}
30
12export class ThreadOutboxStorageError extends Schema.TaggedErrorClass<ThreadOutboxStorageError>()(31export class ThreadOutboxStorageError extends Schema.TaggedErrorClass<ThreadOutboxStorageError>()(
13 "ThreadOutboxStorageError",32 "ThreadOutboxStorageError",
14 {33 {
...@@ -89,11 +108,12 @@ export const expoThreadOutboxStorage: ThreadOutboxStorage = {...@@ -89,11 +108,12 @@ export const expoThreadOutboxStorage: ThreadOutboxStorage = {
89 write: async (message) => {108 write: async (message) => {
90 const fileName = messageFileName(message.messageId);109 const fileName = messageFileName(message.messageId);
91 try {110 try {
92 const file = await getMessageFile(message.messageId);111 await trackInFlightWrite(
93 if (!file.exists) {112 (async () => {
94 file.create({ intermediates: true, overwrite: true });113 const file = await getMessageFile(message.messageId);
95 }114 await writeFileAtomically(file, JSON.stringify(encodeQueuedThreadMessage(message)));
96 file.write(JSON.stringify(encodeQueuedThreadMessage(message)));115 })(),
116 );
97 } catch (cause) {117 } catch (cause) {
98 throw new ThreadOutboxStorageError({118 throw new ThreadOutboxStorageError({
99 operation: "write",119 operation: "write",
apps/mobile/src/state/thread-outbox.ts+12-1
...@@ -3,7 +3,7 @@ import type { EnvironmentId } from "@t3tools/contracts";...@@ -3,7 +3,7 @@ import type { EnvironmentId } from "@t3tools/contracts";
3import { appAtomRegistry } from "./atom-registry";3import { appAtomRegistry } from "./atom-registry";
4import { createThreadOutboxManager } from "./thread-outbox-manager";4import { createThreadOutboxManager } from "./thread-outbox-manager";
5import type { QueuedThreadMessage } from "./thread-outbox-model";5import type { QueuedThreadMessage } from "./thread-outbox-model";
6import { expoThreadOutboxStorage } from "./thread-outbox-storage";6import { expoThreadOutboxStorage, flushThreadOutboxWrites } from "./thread-outbox-storage";
77
8export * from "./thread-outbox-model";8export * from "./thread-outbox-model";
99
...@@ -12,6 +12,17 @@ export const threadOutboxManager = createThreadOutboxManager({...@@ -12,6 +12,17 @@ export const threadOutboxManager = createThreadOutboxManager({
12 storage: expoThreadOutboxStorage,12 storage: expoThreadOutboxStorage,
13});13});
1414
15/**
16 * Lands queued outbox mutations before the JS runtime is torn down (app update
17 * restart). An enqueued message is published to the atom immediately but its
18 * durable write waits behind the mutation queue, so draining only the writes
19 * already mid-file would miss it.
20 */
21export async function flushThreadOutbox(): Promise<void> {
22 await threadOutboxManager.serialize(async () => {});
23 await flushThreadOutboxWrites();
24}
25
15export function ensureThreadOutboxLoaded(): void {26export function ensureThreadOutboxLoaded(): void {
16 void threadOutboxManager.load();27 void threadOutboxManager.load();
17}28}
apps/mobile/src/state/use-composer-drafts.test.ts+39
...@@ -4,6 +4,7 @@ import { vi } from "vite-plus/test";...@@ -4,6 +4,7 @@ import { vi } from "vite-plus/test";
44
5const composerDraftFileMocks = vi.hoisted(() => {5const composerDraftFileMocks = vi.hoisted(() => {
6 let document = "";6 let document = "";
7 let writeError: Error | null = null;
7 let releaseRead: (() => void) | null = null;8 let releaseRead: (() => void) | null = null;
8 let readBarrier = Promise.resolve();9 let readBarrier = Promise.resolve();
910
...@@ -17,23 +18,35 @@ const composerDraftFileMocks = vi.hoisted(() => {...@@ -17,23 +18,35 @@ const composerDraftFileMocks = vi.hoisted(() => {
17 releaseRead?.();18 releaseRead?.();
18 releaseRead = null;19 releaseRead = null;
19 },20 },
21 getDocument() {
22 return document;
23 },
20 setDocument(value: unknown) {24 setDocument(value: unknown) {
21 document = JSON.stringify(value);25 document = JSON.stringify(value);
22 },26 },
27 setWriteError(error: Error | null) {
28 writeError = error;
29 },
23 Directory: class {30 Directory: class {
24 create() {}31 create() {}
25 },32 },
26 File: class {33 File: class {
27 exists = true;34 exists = true;
35 parentDirectory = null;
2836
29 create() {}37 create() {}
3038
39 moveSync() {}
40
31 async text() {41 async text() {
32 await readBarrier;42 await readBarrier;
33 return document;43 return document;
34 }44 }
3545
36 write(value: string) {46 write(value: string) {
47 if (writeError) {
48 throw writeError;
49 }
37 document = value;50 document = value;
38 }51 }
39 },52 },
...@@ -49,15 +62,18 @@ vi.mock("expo-file-system", () => ({...@@ -49,15 +62,18 @@ vi.mock("expo-file-system", () => ({
49import { appAtomRegistry } from "./atom-registry";62import { appAtomRegistry } from "./atom-registry";
50import {63import {
51 clearComposerDraftContentState,64 clearComposerDraftContentState,
65 ComposerDraftPersistenceError,
52 composerDraftsAtom,66 composerDraftsAtom,
53 copyComposerDraftContentIfEmpty,67 copyComposerDraftContentIfEmpty,
54 copyComposerDraftContentState,68 copyComposerDraftContentState,
55 decodePersistedComposerDrafts,69 decodePersistedComposerDrafts,
56 type ComposerDraft,70 type ComposerDraft,
71 flushComposerDrafts,
57 getComposerDraftSnapshot,72 getComposerDraftSnapshot,
58 mergeComposerDraftContentState,73 mergeComposerDraftContentState,
59 removeComposerDraftsForEnvironment,74 removeComposerDraftsForEnvironment,
60 restoreComposerDraftSnapshotState,75 restoreComposerDraftSnapshotState,
76 setComposerDraftText,
61} from "./use-composer-drafts";77} from "./use-composer-drafts";
6278
63const DRAFT: ComposerDraft = {79const DRAFT: ComposerDraft = {
...@@ -393,4 +409,27 @@ describe("mobile composer drafts", () => {...@@ -393,4 +409,27 @@ describe("mobile composer drafts", () => {
393 [unrelatedKey]: unrelated,409 [unrelatedKey]: unrelated,
394 });410 });
395 });411 });
412
413 it("lands a still-debounced draft write when flushed", async () => {
414 const draftKey = "environment-1:thread-1";
415 setComposerDraftText(draftKey, "typed right before the restart");
416
417 await flushComposerDrafts();
418
419 expect(JSON.parse(composerDraftFileMocks.getDocument())).toMatchObject({
420 drafts: { [draftKey]: { text: "typed right before the restart" } },
421 });
422 });
423
424 it("propagates a flush write failure instead of resolving as saved", async () => {
425 const draftKey = "environment-1:thread-1";
426 setComposerDraftText(draftKey, "unsaved");
427 composerDraftFileMocks.setWriteError(new Error("storage unavailable"));
428
429 try {
430 await expect(flushComposerDrafts()).rejects.toBeInstanceOf(ComposerDraftPersistenceError);
431 } finally {
432 composerDraftFileMocks.setWriteError(null);
433 }
434 });
396});435});
apps/mobile/src/state/use-composer-drafts.ts+23-5
...@@ -13,6 +13,7 @@ import * as Schema from "effect/Schema";...@@ -13,6 +13,7 @@ import * as Schema from "effect/Schema";
13import { useEffect } from "react";13import { useEffect } from "react";
14import { Atom } from "effect/unstable/reactivity";14import { Atom } from "effect/unstable/reactivity";
1515
16import { writeFileAtomically } from "../lib/atomic-file";
16import { DraftComposerImageAttachmentSchema } from "../lib/composer-image-schema";17import { DraftComposerImageAttachmentSchema } from "../lib/composer-image-schema";
17import type { DraftComposerImageAttachment } from "../lib/composerImages";18import type { DraftComposerImageAttachment } from "../lib/composerImages";
18import { SerializedAsyncQueue } from "../lib/serialized-async-queue";19import { SerializedAsyncQueue } from "../lib/serialized-async-queue";
...@@ -188,10 +189,7 @@ async function writePersistedComposerDrafts(drafts: Record<string, ComposerDraft...@@ -188,10 +189,7 @@ async function writePersistedComposerDrafts(drafts: Record<string, ComposerDraft
188 } as const;189 } as const;
189 const encoded = JSON.stringify(document);190 const encoded = JSON.stringify(document);
190 operation = "write";191 operation = "write";
191 if (!file.exists) {192 await writeFileAtomically(file, encoded);
192 file.create({ intermediates: true, overwrite: true });
193 }
194 file.write(encoded);
195 } catch (cause) {193 } catch (cause) {
196 throw new ComposerDraftPersistenceError({194 throw new ComposerDraftPersistenceError({
197 operation,195 operation,
...@@ -211,6 +209,26 @@ async function savePersistedComposerDrafts(drafts: Record<string, ComposerDraft>...@@ -211,6 +209,26 @@ async function savePersistedComposerDrafts(drafts: Record<string, ComposerDraft>
211 }209 }
212}210}
213211
212/**
213 * Lands any debounced or in-flight draft write before the JS runtime is torn
214 * down (app update restart), so the freshest draft state survives it. A write
215 * failure propagates so the caller can decide whether the restart may proceed.
216 */
217export async function flushComposerDrafts(): Promise<void> {
218 // An edit during an awaited write schedules another debounced write, so
219 // keep landing snapshots until no debounce is pending after a queue drain.
220 do {
221 while (persistTimer !== null) {
222 clearTimeout(persistTimer);
223 persistTimer = null;
224 await persistenceQueue.run(() =>
225 writePersistedComposerDrafts(appAtomRegistry.get(composerDraftsAtom)),
226 );
227 }
228 await persistenceQueue.run(() => Promise.resolve());
229 } while (persistTimer !== null);
230}
231
214function schedulePersistComposerDrafts(drafts: Record<string, ComposerDraft>): void {232function schedulePersistComposerDrafts(drafts: Record<string, ComposerDraft>): void {
215 if (persistTimer !== null) {233 if (persistTimer !== null) {
216 clearTimeout(persistTimer);234 clearTimeout(persistTimer);
...@@ -627,7 +645,7 @@ export async function clearComposerDraftsEnvironment(environmentId: EnvironmentI...@@ -627,7 +645,7 @@ export async function clearComposerDraftsEnvironment(environmentId: EnvironmentI
627 persistTimer = null;645 persistTimer = null;
628 }646 }
629 appAtomRegistry.set(composerDraftsAtom, next);647 appAtomRegistry.set(composerDraftsAtom, next);
630 await writePersistedComposerDrafts(next);648 await persistenceQueue.run(() => writePersistedComposerDrafts(next));
631}649}
632650
633export function useComposerDraft(draftKey: string | null): ComposerDraft {651export function useComposerDraft(draftKey: string | null): ComposerDraft {
docs/user/updating.md+8
...@@ -70,4 +70,12 @@ If a step fails:...@@ -70,4 +70,12 @@ If a step fails:
703. For a command-line server, relaunch it with `npx t3@<client-version>`, replacing703. For a command-line server, relaunch it with `npx t3@<client-version>`, replacing
71 `<client-version>` with the client version shown in the warning.71 `<client-version>` with the client version shown in the warning.
7272
73## The Mobile App
74
75The mobile app keeps itself current on its own. When it finds a new version, it downloads it in the
76background and installs it automatically the next time you leave the app. Unsent drafts and queued
77messages are saved before the restart. Only if the app stays open long enough that the update never
78gets that chance does it ask whether to install right away; choosing **Later** is safe and keeps the
79automatic install armed.
80
73For remote connection setup and access troubleshooting, see [Remote Access](./remote-access.md).81For remote connection setup and access troubleshooting, see [Remote Access](./remote-access.md).