@remit/web-client 0.0.270 → 0.0.271
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/hooks/update-restart-escalation.render.test.ts +340 -0
- package/src/hooks/use-system-update.ts +49 -2
- package/src/lib/error-classifier.test.ts +59 -0
- package/src/lib/error-classifier.ts +48 -2
- package/src/lib/self-update-state.test.ts +1 -0
- package/src/lib/self-update-state.ts +138 -30
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.271",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Remit web client, published as composable primitives — the app shell, auth shells, and runtime config. A distributor imports what it composes and bundles it.",
|
|
6
6
|
"exports": {
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The update poll against the app's own fail-fast sink (#468).
|
|
3
|
+
*
|
|
4
|
+
* An update stops and starts the backend on purpose, so the proxy in front of
|
|
5
|
+
* it answers 502 for the seconds it is gone. Every other test in this package
|
|
6
|
+
* mounts on the harness's plain `QueryClient`, which has no global sink, so none
|
|
7
|
+
* of them can see that the 502 took the whole screen before the update surface
|
|
8
|
+
* ever read it. Mounted here on the client `shell/index.tsx` builds.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import assert from "node:assert/strict";
|
|
12
|
+
import { afterEach, beforeEach, describe, test } from "node:test";
|
|
13
|
+
import { MutationCache, QueryCache, QueryClient } from "@tanstack/react-query";
|
|
14
|
+
import { act, createElement } from "react";
|
|
15
|
+
import { SelfUpdateOverlay } from "@/components/self-update/SelfUpdateOverlay";
|
|
16
|
+
import { __resetFatalError, getCurrentFatalError } from "@/lib/fatal-error";
|
|
17
|
+
import {
|
|
18
|
+
handleMutationCacheError,
|
|
19
|
+
handleQueryCacheError,
|
|
20
|
+
} from "@/lib/query-error-handler";
|
|
21
|
+
import {
|
|
22
|
+
APPLY_BUDGET_SECONDS,
|
|
23
|
+
NEVER_CAME_BACK_MARGIN_SECONDS,
|
|
24
|
+
} from "@/lib/self-update-state";
|
|
25
|
+
import { createDomHarness, type DomHarness } from "../test-support/dom";
|
|
26
|
+
import { type HttpMock, httpError, mockFetch } from "../test-support/http";
|
|
27
|
+
import {
|
|
28
|
+
type SelfUpdateApi,
|
|
29
|
+
SelfUpdateProvider,
|
|
30
|
+
useSelfUpdate,
|
|
31
|
+
} from "./use-system-update";
|
|
32
|
+
import "@/lib/client";
|
|
33
|
+
|
|
34
|
+
const BUDGET_MS =
|
|
35
|
+
(APPLY_BUDGET_SECONDS + NEVER_CAME_BACK_MARGIN_SECONDS) * 1000;
|
|
36
|
+
|
|
37
|
+
let harness: DomHarness | undefined;
|
|
38
|
+
let http: HttpMock | undefined;
|
|
39
|
+
let api: SelfUpdateApi | undefined;
|
|
40
|
+
|
|
41
|
+
beforeEach(() => {
|
|
42
|
+
__resetFatalError();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
afterEach(() => {
|
|
46
|
+
__resetFatalError();
|
|
47
|
+
http?.restore();
|
|
48
|
+
http = undefined;
|
|
49
|
+
harness?.close();
|
|
50
|
+
harness = undefined;
|
|
51
|
+
api = undefined;
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const available = {
|
|
55
|
+
currentVersion: "0.9.3",
|
|
56
|
+
check: {
|
|
57
|
+
status: "ok",
|
|
58
|
+
updateAvailable: true,
|
|
59
|
+
latestVersion: "0.9.4",
|
|
60
|
+
publishedAt: "2026-07-14T09:00:00.000Z",
|
|
61
|
+
summary: "Faster first sync.",
|
|
62
|
+
releaseNotesUrl: "https://example.test/notes",
|
|
63
|
+
},
|
|
64
|
+
run: null,
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const accepted = {
|
|
68
|
+
currentVersion: "0.9.3",
|
|
69
|
+
check: { status: "ok", updateAvailable: true, latestVersion: "0.9.4" },
|
|
70
|
+
run: {
|
|
71
|
+
runId: "upd_1",
|
|
72
|
+
fromVersion: "0.9.3",
|
|
73
|
+
targetVersion: "0.9.4",
|
|
74
|
+
phase: "stopping",
|
|
75
|
+
outcome: null,
|
|
76
|
+
startedAt: "2026-07-20T11:59:30.000Z",
|
|
77
|
+
updatedAt: "2026-07-20T11:59:45.000Z",
|
|
78
|
+
message: "Restarting Remit on 0.9.4.",
|
|
79
|
+
logCommand: "remit logs --since 10m",
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The run as a tab that never pressed install finds it: reported by the server,
|
|
85
|
+
* started just now, so the wait is measured from a start the page did not see.
|
|
86
|
+
*/
|
|
87
|
+
const reportedRunning = () => ({
|
|
88
|
+
...accepted,
|
|
89
|
+
run: { ...accepted.run, startedAt: new Date().toISOString() },
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
/** The client `shell/index.tsx` builds: the real global escalation sink. */
|
|
93
|
+
const appQueryClient = (): QueryClient =>
|
|
94
|
+
new QueryClient({
|
|
95
|
+
queryCache: new QueryCache({ onError: handleQueryCacheError }),
|
|
96
|
+
mutationCache: new MutationCache({ onError: handleMutationCacheError }),
|
|
97
|
+
defaultOptions: {
|
|
98
|
+
queries: { retry: false },
|
|
99
|
+
mutations: { retry: false },
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const hook = (): SelfUpdateApi => {
|
|
104
|
+
if (!api) throw new Error("hook not mounted");
|
|
105
|
+
return api;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const dom = (): DomHarness => {
|
|
109
|
+
if (!harness) throw new Error("nothing mounted");
|
|
110
|
+
return harness;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
/** Mount the surface, with `poll` deciding what each `GET /system/update` answers. */
|
|
114
|
+
async function mount(poll: () => unknown): Promise<void> {
|
|
115
|
+
http = mockFetch((call) => {
|
|
116
|
+
if (call.path.endsWith("/system/update") && call.method === "GET") {
|
|
117
|
+
return poll();
|
|
118
|
+
}
|
|
119
|
+
return accepted;
|
|
120
|
+
});
|
|
121
|
+
const Probe = () => {
|
|
122
|
+
api = useSelfUpdate();
|
|
123
|
+
return null;
|
|
124
|
+
};
|
|
125
|
+
harness = createDomHarness({ queryClient: appQueryClient() });
|
|
126
|
+
harness.renderApp(
|
|
127
|
+
createElement(
|
|
128
|
+
SelfUpdateProvider,
|
|
129
|
+
null,
|
|
130
|
+
createElement(Probe),
|
|
131
|
+
createElement(SelfUpdateOverlay),
|
|
132
|
+
),
|
|
133
|
+
);
|
|
134
|
+
await settle();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function settle(): Promise<void> {
|
|
138
|
+
for (let round = 0; round < 40; round += 1) {
|
|
139
|
+
await dom().flush();
|
|
140
|
+
await dom().wait(0);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Press install and wait for the run this page now holds to reach the surface. */
|
|
145
|
+
async function install(): Promise<void> {
|
|
146
|
+
await act(async () => {
|
|
147
|
+
hook().install("0.9.4");
|
|
148
|
+
await dom().flush();
|
|
149
|
+
});
|
|
150
|
+
await dom().waitFor(() => {
|
|
151
|
+
const surface = hook().surface;
|
|
152
|
+
return surface.status === "ready" && surface.overlay.kind === "applying";
|
|
153
|
+
}, "the install to be accepted");
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function repoll(): Promise<void> {
|
|
157
|
+
await act(async () => {
|
|
158
|
+
hook().onRetryConnection();
|
|
159
|
+
await dom().flush();
|
|
160
|
+
});
|
|
161
|
+
await settle();
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
describe("the update poll across the restart it asked for (#468)", () => {
|
|
165
|
+
test("a 502 while a run is held keeps the update surface, not the fatal page", async () => {
|
|
166
|
+
let restarting = false;
|
|
167
|
+
await mount(() => (restarting ? httpError(502) : available));
|
|
168
|
+
await install();
|
|
169
|
+
|
|
170
|
+
restarting = true;
|
|
171
|
+
await repoll();
|
|
172
|
+
|
|
173
|
+
assert.equal(getCurrentFatalError(), null);
|
|
174
|
+
const surface = hook().surface;
|
|
175
|
+
assert.equal(
|
|
176
|
+
surface.status === "ready" &&
|
|
177
|
+
surface.section.status === "applying" &&
|
|
178
|
+
surface.section.phase,
|
|
179
|
+
"reconnecting",
|
|
180
|
+
);
|
|
181
|
+
assert.match(dom().html(), /Installing Remit 0\.9\.4/);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("the same 502 with no run held escalates", async () => {
|
|
185
|
+
await mount(() => httpError(502));
|
|
186
|
+
|
|
187
|
+
assert.equal(getCurrentFatalError()?.error !== undefined, true);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("a 502 with no run known from either source escalates", async () => {
|
|
191
|
+
// The server has answered, and what it said was that nothing is running.
|
|
192
|
+
let restarting = false;
|
|
193
|
+
await mount(() => (restarting ? httpError(502) : available));
|
|
194
|
+
|
|
195
|
+
restarting = true;
|
|
196
|
+
await repoll();
|
|
197
|
+
|
|
198
|
+
assert.equal(getCurrentFatalError()?.error !== undefined, true);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("a connection refused inside the window stays soft, as any transport failure does", async () => {
|
|
202
|
+
let restarting = false;
|
|
203
|
+
await mount(() => {
|
|
204
|
+
if (restarting) throw new Error("connection refused");
|
|
205
|
+
return available;
|
|
206
|
+
});
|
|
207
|
+
await install();
|
|
208
|
+
|
|
209
|
+
restarting = true;
|
|
210
|
+
await repoll();
|
|
211
|
+
|
|
212
|
+
assert.equal(getCurrentFatalError(), null);
|
|
213
|
+
assert.match(dom().html(), /Installing Remit 0\.9\.4/);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test("a 500 while a run is held escalates — the server answered", async () => {
|
|
217
|
+
let broken = false;
|
|
218
|
+
await mount(() => (broken ? httpError(500) : available));
|
|
219
|
+
await install();
|
|
220
|
+
|
|
221
|
+
broken = true;
|
|
222
|
+
await repoll();
|
|
223
|
+
|
|
224
|
+
assert.equal(getCurrentFatalError()?.error !== undefined, true);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test("a 502 past the apply budget gives up loudly, still not fatally", async () => {
|
|
228
|
+
let restarting = false;
|
|
229
|
+
await mount(() => (restarting ? httpError(502) : available));
|
|
230
|
+
await install();
|
|
231
|
+
|
|
232
|
+
restarting = true;
|
|
233
|
+
const realNow = Date.now;
|
|
234
|
+
Date.now = () => realNow() + BUDGET_MS + 60_000;
|
|
235
|
+
try {
|
|
236
|
+
await repoll();
|
|
237
|
+
|
|
238
|
+
assert.equal(getCurrentFatalError(), null);
|
|
239
|
+
assert.match(dom().html(), /has not answered since the restart/);
|
|
240
|
+
assert.match(dom().html(), /remit logs/);
|
|
241
|
+
assert.doesNotMatch(dom().html(), /Installing Remit 0\.9\.4/);
|
|
242
|
+
} finally {
|
|
243
|
+
Date.now = realNow;
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* The overlay speaks for a run in any tab the server tells about it, so the
|
|
250
|
+
* restart has to be ridden there too — a second tab, or the initiating one after
|
|
251
|
+
* a reload, holds nothing of its own.
|
|
252
|
+
*/
|
|
253
|
+
describe("a tab that never pressed install (#468)", () => {
|
|
254
|
+
test("holds the applying overlay across a 502", async () => {
|
|
255
|
+
let restarting = false;
|
|
256
|
+
await mount(() => (restarting ? httpError(502) : reportedRunning()));
|
|
257
|
+
assert.match(dom().html(), /Installing Remit 0\.9\.4/);
|
|
258
|
+
|
|
259
|
+
restarting = true;
|
|
260
|
+
await repoll();
|
|
261
|
+
|
|
262
|
+
assert.equal(getCurrentFatalError(), null);
|
|
263
|
+
assert.match(dom().html(), /Installing Remit 0\.9\.4/);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test("gives up loudly past the same apply budget, and names the run's own log command", async () => {
|
|
267
|
+
let restarting = false;
|
|
268
|
+
await mount(() => (restarting ? httpError(502) : reportedRunning()));
|
|
269
|
+
|
|
270
|
+
restarting = true;
|
|
271
|
+
const realNow = Date.now;
|
|
272
|
+
Date.now = () => realNow() + BUDGET_MS + 60_000;
|
|
273
|
+
try {
|
|
274
|
+
await repoll();
|
|
275
|
+
|
|
276
|
+
assert.equal(getCurrentFatalError(), null);
|
|
277
|
+
assert.match(dom().html(), /has not answered since the restart/);
|
|
278
|
+
assert.match(dom().html(), /remit logs --since 10m/);
|
|
279
|
+
assert.doesNotMatch(dom().html(), /Installing Remit 0\.9\.4/);
|
|
280
|
+
} finally {
|
|
281
|
+
Date.now = realNow;
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
test("a box clock behind the browser's does not spend the budget before the first 502", async () => {
|
|
286
|
+
// `startedAt` is written on the box. Twenty minutes of skew is more than
|
|
287
|
+
// the whole budget, so measuring the wait from it would open the screen on
|
|
288
|
+
// the give-up and never show the install at all.
|
|
289
|
+
const skewed = () => ({
|
|
290
|
+
...accepted.run,
|
|
291
|
+
startedAt: new Date(Date.now() - BUDGET_MS - 60_000).toISOString(),
|
|
292
|
+
});
|
|
293
|
+
let restarting = false;
|
|
294
|
+
await mount(() =>
|
|
295
|
+
restarting ? httpError(502) : { ...accepted, run: skewed() },
|
|
296
|
+
);
|
|
297
|
+
|
|
298
|
+
restarting = true;
|
|
299
|
+
await repoll();
|
|
300
|
+
|
|
301
|
+
assert.equal(getCurrentFatalError(), null);
|
|
302
|
+
assert.match(dom().html(), /Installing Remit 0\.9\.4/);
|
|
303
|
+
assert.doesNotMatch(dom().html(), /has not answered since the restart/);
|
|
304
|
+
|
|
305
|
+
// The wait is this tab's, so it still runs out — on this tab's clock.
|
|
306
|
+
const realNow = Date.now;
|
|
307
|
+
Date.now = () => realNow() + BUDGET_MS + 60_000;
|
|
308
|
+
try {
|
|
309
|
+
await repoll();
|
|
310
|
+
|
|
311
|
+
assert.equal(getCurrentFatalError(), null);
|
|
312
|
+
assert.match(dom().html(), /has not answered since the restart/);
|
|
313
|
+
} finally {
|
|
314
|
+
Date.now = realNow;
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
test("says the service is unreachable once the run it knew about is finished", async () => {
|
|
319
|
+
let restarting = false;
|
|
320
|
+
await mount(() =>
|
|
321
|
+
restarting
|
|
322
|
+
? httpError(502)
|
|
323
|
+
: {
|
|
324
|
+
currentVersion: "0.9.4",
|
|
325
|
+
check: { status: "ok", updateAvailable: false },
|
|
326
|
+
run: { ...accepted.run, outcome: "succeeded" },
|
|
327
|
+
},
|
|
328
|
+
);
|
|
329
|
+
|
|
330
|
+
restarting = true;
|
|
331
|
+
await repoll();
|
|
332
|
+
|
|
333
|
+
const surface = hook().surface;
|
|
334
|
+
assert.equal(
|
|
335
|
+
surface.status === "ready" && surface.section.status,
|
|
336
|
+
"checkFailed",
|
|
337
|
+
);
|
|
338
|
+
assert.equal(getCurrentFatalError()?.error !== undefined, true);
|
|
339
|
+
});
|
|
340
|
+
});
|
|
@@ -37,7 +37,11 @@ import {
|
|
|
37
37
|
useRef,
|
|
38
38
|
useState,
|
|
39
39
|
} from "react";
|
|
40
|
-
import {
|
|
40
|
+
import {
|
|
41
|
+
restartExpectedMeta,
|
|
42
|
+
shouldEscalate,
|
|
43
|
+
softErrorMeta,
|
|
44
|
+
} from "@/lib/error-classifier";
|
|
41
45
|
import { reportFatalError } from "@/lib/fatal-error";
|
|
42
46
|
import {
|
|
43
47
|
appliesSchemaMigration,
|
|
@@ -48,13 +52,30 @@ import {
|
|
|
48
52
|
type HeldRun,
|
|
49
53
|
isSurfaceAbsent,
|
|
50
54
|
mapUpdatePhase,
|
|
55
|
+
type RunSighting,
|
|
51
56
|
releaseFromCheck,
|
|
57
|
+
runInFlight,
|
|
52
58
|
type UpdateSurface,
|
|
53
59
|
} from "@/lib/self-update-state";
|
|
54
60
|
|
|
55
61
|
const IDLE_POLL_MS = 30_000;
|
|
56
62
|
const RUN_POLL_MS = 5_000;
|
|
57
63
|
|
|
64
|
+
/**
|
|
65
|
+
* The poll's own error UX, and — while a run is known to be in flight — the
|
|
66
|
+
* restart that run performs. Stopping and starting the backend is what the run
|
|
67
|
+
* is, so the gateway statuses answered while it is down belong to the surface
|
|
68
|
+
* below, which shows the phase and gives up loudly if the server never comes
|
|
69
|
+
* back (#468). Outside that window the poll carries no such claim.
|
|
70
|
+
*
|
|
71
|
+
* "Known" is either source: the run this page started and holds, or the run the
|
|
72
|
+
* server last reported as going. The overlay speaks for a run in any tab, so the
|
|
73
|
+
* window it rides has to open in any tab too — a second tab, or this one after a
|
|
74
|
+
* reload, holds nothing and would otherwise meet the restart with no claim on it.
|
|
75
|
+
*/
|
|
76
|
+
const POLL_META = softErrorMeta;
|
|
77
|
+
const POLL_ACROSS_RESTART_META = { ...softErrorMeta, ...restartExpectedMeta };
|
|
78
|
+
|
|
58
79
|
/**
|
|
59
80
|
* How long a pressed check waits for the updater before the pane calls it a
|
|
60
81
|
* failure. The backend only records the request; the updater picks it up on a
|
|
@@ -105,16 +126,25 @@ export function useSystemUpdate(): SelfUpdateApi {
|
|
|
105
126
|
const [dismissedRunId, setDismissedRunId] = useState<string | null>(null);
|
|
106
127
|
const [checkPress, setCheckPress] = useState<CheckPress | null>(null);
|
|
107
128
|
const [checkFailure, setCheckFailure] = useState<string | null>(null);
|
|
129
|
+
const [sighting, setSighting] = useState<RunSighting | null>(null);
|
|
108
130
|
|
|
109
131
|
const heldRef = useRef(held);
|
|
110
132
|
heldRef.current = held;
|
|
111
133
|
const pressRef = useRef(checkPress);
|
|
112
134
|
pressRef.current = checkPress;
|
|
113
135
|
|
|
136
|
+
// The last answer, which outlives the failed requests after it: a tab that
|
|
137
|
+
// never pressed install learns from here that it is inside a restart.
|
|
138
|
+
const lastKnown = queryClient.getQueryData<RemitImapSystemUpdateResponse>(
|
|
139
|
+
systemOperationsGetSystemUpdateQueryKey(),
|
|
140
|
+
);
|
|
141
|
+
const insideRestart =
|
|
142
|
+
held !== null || runInFlight(lastKnown, dismissedRunId) !== null;
|
|
143
|
+
|
|
114
144
|
const query = useQuery({
|
|
115
145
|
...systemOperationsGetSystemUpdateOptions(),
|
|
116
146
|
retry: false,
|
|
117
|
-
meta:
|
|
147
|
+
meta: insideRestart ? POLL_ACROSS_RESTART_META : POLL_META,
|
|
118
148
|
refetchInterval: (query) =>
|
|
119
149
|
pollInterval(
|
|
120
150
|
query.state.error,
|
|
@@ -135,9 +165,26 @@ export function useSystemUpdate(): SelfUpdateApi {
|
|
|
135
165
|
dismissedRunId,
|
|
136
166
|
checkPress,
|
|
137
167
|
checkFailure,
|
|
168
|
+
sighting,
|
|
138
169
|
now: Date.now(),
|
|
139
170
|
});
|
|
140
171
|
|
|
172
|
+
// Stamp the run the server reports, once, off this tab's clock. The stamp is
|
|
173
|
+
// what the give-up is measured against, so it must not move while the server
|
|
174
|
+
// is unreachable — and it must not carry over to the next run.
|
|
175
|
+
const reported = runInFlight(query.data, dismissedRunId);
|
|
176
|
+
useEffect(() => {
|
|
177
|
+
if (reported === null) {
|
|
178
|
+
setSighting((current) => (current === null ? current : null));
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
setSighting((current) =>
|
|
182
|
+
current !== null && current.runId === reported.runId
|
|
183
|
+
? current
|
|
184
|
+
: { runId: reported.runId, observedAt: Date.now() },
|
|
185
|
+
);
|
|
186
|
+
}, [reported]);
|
|
187
|
+
|
|
141
188
|
const shownRunIdRef = useRef<string | null>(null);
|
|
142
189
|
shownRunIdRef.current =
|
|
143
190
|
derived.surface.status === "ready" && "runId" in derived.surface.section
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
isNetworkError,
|
|
10
10
|
isNotFound,
|
|
11
11
|
isServerError,
|
|
12
|
+
restartExpectedMeta,
|
|
12
13
|
shouldEscalate,
|
|
13
14
|
softErrorStatuses,
|
|
14
15
|
} from "./error-classifier";
|
|
@@ -366,6 +367,13 @@ describe("meta.softErrorStatuses (one outcome owned, the rest not)", () => {
|
|
|
366
367
|
);
|
|
367
368
|
});
|
|
368
369
|
|
|
370
|
+
it("does not open the restart gap for a call site that never claimed one", () => {
|
|
371
|
+
assert.equal(
|
|
372
|
+
shouldEscalate(new ApiError("bad gateway", 502), ownsNotFound, "nobody"),
|
|
373
|
+
true,
|
|
374
|
+
);
|
|
375
|
+
});
|
|
376
|
+
|
|
369
377
|
it("names the record's absence, whichever client raised it", () => {
|
|
370
378
|
assert.equal(isNotFound(new ApiError("gone", 404)), true);
|
|
371
379
|
assert.equal(isNotFound({ status: 404 }), true);
|
|
@@ -373,3 +381,54 @@ describe("meta.softErrorStatuses (one outcome owned, the rest not)", () => {
|
|
|
373
381
|
assert.equal(isNotFound(new TypeError("x is undefined")), false);
|
|
374
382
|
});
|
|
375
383
|
});
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* A self-update stops and starts the backend, so for those seconds the proxy in
|
|
387
|
+
* front of it has nothing to dial. That gap belongs to the surface that asked
|
|
388
|
+
* for the restart; every other 5xx, on that call site and on any other, is still
|
|
389
|
+
* the API reporting itself broken (#468).
|
|
390
|
+
*/
|
|
391
|
+
describe("meta.restartExpected (the gap in a restart this call site asked for)", () => {
|
|
392
|
+
it("does NOT escalate the gateway statuses answered while the backend is down", () => {
|
|
393
|
+
for (const status of [502, 503, 504]) {
|
|
394
|
+
assert.equal(
|
|
395
|
+
shouldEscalate(
|
|
396
|
+
new ApiError("gateway", status),
|
|
397
|
+
restartExpectedMeta,
|
|
398
|
+
"nobody",
|
|
399
|
+
),
|
|
400
|
+
false,
|
|
401
|
+
`a ${status} inside the restart window belongs to the update surface`,
|
|
402
|
+
);
|
|
403
|
+
}
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
it("escalates a 500 — a server that answered is up, and up and broken is not designed", () => {
|
|
407
|
+
assert.equal(
|
|
408
|
+
shouldEscalate(new ApiError("boom", 500), restartExpectedMeta, "nobody"),
|
|
409
|
+
true,
|
|
410
|
+
);
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
it("escalates the same 502 without the claim, however soft the call site is", () => {
|
|
414
|
+
assert.equal(
|
|
415
|
+
shouldEscalate(new ApiError("gateway", 502), undefined, "nobody"),
|
|
416
|
+
true,
|
|
417
|
+
);
|
|
418
|
+
assert.equal(
|
|
419
|
+
shouldEscalate(new ApiError("gateway", 502), { softError: true }, "user"),
|
|
420
|
+
true,
|
|
421
|
+
);
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
it("escalates a client bug raised inside the window", () => {
|
|
425
|
+
assert.equal(
|
|
426
|
+
shouldEscalate(
|
|
427
|
+
new TypeError("x is undefined"),
|
|
428
|
+
restartExpectedMeta,
|
|
429
|
+
"nobody",
|
|
430
|
+
),
|
|
431
|
+
true,
|
|
432
|
+
);
|
|
433
|
+
});
|
|
434
|
+
});
|
|
@@ -143,6 +143,33 @@ const isSoftErrorMeta = (
|
|
|
143
143
|
return status !== undefined && owned.includes(status);
|
|
144
144
|
};
|
|
145
145
|
|
|
146
|
+
/**
|
|
147
|
+
* The statuses answered for the seconds a service is not there. Every `/api/*`
|
|
148
|
+
* request is proxied to apisix, which stays up while the backend behind it
|
|
149
|
+
* restarts and refuses in milliseconds — the 29ms 502 in #468, not a dial Caddy
|
|
150
|
+
* waited out. 503 and 504 are the same gap through a hop that answers
|
|
151
|
+
* differently.
|
|
152
|
+
*/
|
|
153
|
+
const RESTART_GAP_STATUSES = [502, 503, 504];
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* The gap in a restart this call site asked for. A self-update stops and starts
|
|
157
|
+
* the backend on purpose, so the poll watching that run meets a proxy with
|
|
158
|
+
* nothing behind it — the designed shape of the operation the user pressed, not
|
|
159
|
+
* our API answering "I'm broken".
|
|
160
|
+
*
|
|
161
|
+
* A 500 is not in it: a server that answered is a server that is up, and up and
|
|
162
|
+
* broken is never designed.
|
|
163
|
+
*/
|
|
164
|
+
const isDesignedRestartGap = (
|
|
165
|
+
error: unknown,
|
|
166
|
+
meta: Record<string, unknown> | undefined,
|
|
167
|
+
): boolean => {
|
|
168
|
+
if (meta?.restartExpected !== true) return false;
|
|
169
|
+
const status = getErrorStatus(error);
|
|
170
|
+
return status !== undefined && RESTART_GAP_STATUSES.includes(status);
|
|
171
|
+
};
|
|
172
|
+
|
|
146
173
|
/**
|
|
147
174
|
* The single fail-fast decision: should this error escalate to the full-screen
|
|
148
175
|
* fatal overlay? Default is YES — a non-2xx must never silently vanish.
|
|
@@ -151,7 +178,10 @@ const isSoftErrorMeta = (
|
|
|
151
178
|
* 1. DEFAULT = escalate.
|
|
152
179
|
* 2. A 5xx (500–599) ALWAYS escalates — no opt-out, even on a background
|
|
153
180
|
* refetch, even when the call site marked `meta.softError`. Our API
|
|
154
|
-
* answered "I'm broken"; that is never benign.
|
|
181
|
+
* answered "I'm broken"; that is never benign. The single exception is a
|
|
182
|
+
* 502/503/504 on a call site that declared it is polling across a restart it
|
|
183
|
+
* asked for (`meta.restartExpected`, #468): there the backend being gone is
|
|
184
|
+
* the operation working, and that call site renders the wait itself.
|
|
155
185
|
* 3. A client-side exception ALWAYS escalates, on the same terms. It is our
|
|
156
186
|
* bug; there is nothing for the user to retry and nothing to dismiss.
|
|
157
187
|
* 4. A 401 on a request the user is waiting on ALWAYS escalates, on the same
|
|
@@ -173,7 +203,7 @@ export const shouldEscalate = (
|
|
|
173
203
|
meta?: Record<string, unknown>,
|
|
174
204
|
awaiting: Awaiting = "nobody",
|
|
175
205
|
): boolean => {
|
|
176
|
-
if (isServerError(error)) return
|
|
206
|
+
if (isServerError(error)) return !isDesignedRestartGap(error, meta);
|
|
177
207
|
if (isAbortError(error)) return false;
|
|
178
208
|
if (isNetworkError(error)) return false;
|
|
179
209
|
if (awaiting === "user" && isUnauthenticated(error)) return true;
|
|
@@ -209,6 +239,22 @@ export const softErrorStatuses = (
|
|
|
209
239
|
...statuses: number[]
|
|
210
240
|
): { softErrorStatuses: number[] } => ({ softErrorStatuses: statuses });
|
|
211
241
|
|
|
242
|
+
/**
|
|
243
|
+
* The one opt-out from rule 2, for a request watching a restart the run it is
|
|
244
|
+
* following performs. Set it only while a run is known to be in flight — the
|
|
245
|
+
* update poll carries it while it holds a run it started or the server's last
|
|
246
|
+
* answer reported one going — because a 502 outside that window is the API being
|
|
247
|
+
* broken, which is exactly what rule 2 is for.
|
|
248
|
+
*
|
|
249
|
+
* A call site that sets this owns the whole wait, so the window outlives the
|
|
250
|
+
* budget: the give-up screen keeps polling a server it has stopped expecting,
|
|
251
|
+
* and a fatal page thrown over that verdict would replace the one screen that
|
|
252
|
+
* names the run and the log to read. A 500 from that same poll still escalates.
|
|
253
|
+
*/
|
|
254
|
+
export const restartExpectedMeta: { restartExpected: true } = {
|
|
255
|
+
restartExpected: true,
|
|
256
|
+
};
|
|
257
|
+
|
|
212
258
|
/** The record is not there. */
|
|
213
259
|
export const isNotFound = (error: unknown): boolean =>
|
|
214
260
|
getErrorStatus(error) === 404;
|
|
@@ -72,6 +72,21 @@ export interface CheckPress {
|
|
|
72
72
|
since: string | undefined;
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
/**
|
|
76
|
+
* When this tab first saw the run the server reported as going, by its own
|
|
77
|
+
* clock. A run's `startedAt` is written on the box, and a box whose clock is
|
|
78
|
+
* twenty minutes behind the browser's would put that start far enough in the
|
|
79
|
+
* past to spend the whole apply budget before the first failed request — the
|
|
80
|
+
* screen would open on "the server never came back". `CheckPress` keeps its wait
|
|
81
|
+
* the client's own for the same reason. The runId travels with it so a second
|
|
82
|
+
* run never inherits the first one's wait.
|
|
83
|
+
*/
|
|
84
|
+
export interface RunSighting {
|
|
85
|
+
runId: string;
|
|
86
|
+
/** Epoch millis, this tab's clock, of the first answer that reported it going. */
|
|
87
|
+
observedAt: number;
|
|
88
|
+
}
|
|
89
|
+
|
|
75
90
|
export function checkAnswered(
|
|
76
91
|
press: CheckPress,
|
|
77
92
|
data: RemitImapSystemUpdateResponse | undefined,
|
|
@@ -126,6 +141,8 @@ export interface DeriveInput {
|
|
|
126
141
|
checkPress: CheckPress | null;
|
|
127
142
|
/** Why the request that press fired failed, or null when it did not. */
|
|
128
143
|
checkFailure: string | null;
|
|
144
|
+
/** When this tab first saw the server's run going, for a wait it can trust. */
|
|
145
|
+
sighting: RunSighting | null;
|
|
129
146
|
now: number;
|
|
130
147
|
}
|
|
131
148
|
|
|
@@ -163,6 +180,22 @@ export function isSurfaceAbsent(error: unknown): boolean {
|
|
|
163
180
|
return status === 404 || status === 403 || status === 401;
|
|
164
181
|
}
|
|
165
182
|
|
|
183
|
+
/**
|
|
184
|
+
* The run the server last reported as still going, or null when it reported
|
|
185
|
+
* none. The last answer outlives the failed requests that follow it, which is
|
|
186
|
+
* what lets any tab — not only the one that pressed install — know it is inside
|
|
187
|
+
* a restart rather than looking at an unreachable service (#468). A run the user
|
|
188
|
+
* has dismissed is not one either surface still speaks for.
|
|
189
|
+
*/
|
|
190
|
+
export function runInFlight(
|
|
191
|
+
data: RemitImapSystemUpdateResponse | undefined,
|
|
192
|
+
dismissedRunId: string | null,
|
|
193
|
+
): RemitImapSystemUpdateRun | null {
|
|
194
|
+
const run = data?.run ?? null;
|
|
195
|
+
if (run === null || run.outcome !== null) return null;
|
|
196
|
+
return run.runId === dismissedRunId ? null : run;
|
|
197
|
+
}
|
|
198
|
+
|
|
166
199
|
/**
|
|
167
200
|
* The available release from the check block, or undefined when the check
|
|
168
201
|
* reports none. Independent of what the pane is currently showing, so a retry
|
|
@@ -343,29 +376,75 @@ function ready(
|
|
|
343
376
|
return { surface: { status: "ready", section, overlay }, releaseHeld };
|
|
344
377
|
}
|
|
345
378
|
|
|
379
|
+
/**
|
|
380
|
+
* The wait every tab is in while the server it is polling is not answering: the
|
|
381
|
+
* run is known to be in flight, and the phase is the only one the client can
|
|
382
|
+
* claim from a dead connection.
|
|
383
|
+
*/
|
|
384
|
+
function reconnectingSurface(
|
|
385
|
+
runId: string,
|
|
386
|
+
previousVersion: string,
|
|
387
|
+
attemptedVersion: string,
|
|
388
|
+
elapsedSeconds: number,
|
|
389
|
+
): UpdateSurface {
|
|
390
|
+
return {
|
|
391
|
+
status: "ready",
|
|
392
|
+
section: applyingSection(
|
|
393
|
+
runId,
|
|
394
|
+
previousVersion,
|
|
395
|
+
attemptedVersion,
|
|
396
|
+
"reconnecting",
|
|
397
|
+
elapsedSeconds,
|
|
398
|
+
),
|
|
399
|
+
overlay: {
|
|
400
|
+
kind: "applying",
|
|
401
|
+
target: attemptedVersion,
|
|
402
|
+
phase: "reconnecting",
|
|
403
|
+
elapsedSeconds,
|
|
404
|
+
},
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/** The client gave up waiting, and says so without claiming the rollback ran. */
|
|
409
|
+
function neverCameBackSurface(
|
|
410
|
+
runId: string,
|
|
411
|
+
previousVersion: string,
|
|
412
|
+
attemptedVersion: string,
|
|
413
|
+
elapsedSeconds: number,
|
|
414
|
+
logsCommand: string,
|
|
415
|
+
): UpdateSurface {
|
|
416
|
+
return {
|
|
417
|
+
status: "ready",
|
|
418
|
+
section: applyingSection(
|
|
419
|
+
runId,
|
|
420
|
+
previousVersion,
|
|
421
|
+
attemptedVersion,
|
|
422
|
+
"reconnecting",
|
|
423
|
+
elapsedSeconds,
|
|
424
|
+
),
|
|
425
|
+
overlay: {
|
|
426
|
+
kind: "neverCameBack",
|
|
427
|
+
attemptedVersion,
|
|
428
|
+
previousVersion,
|
|
429
|
+
elapsedSeconds,
|
|
430
|
+
logsCommand,
|
|
431
|
+
},
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
|
|
346
435
|
/**
|
|
347
436
|
* The client gave up waiting. The hold stays: the screen has to sit still, and
|
|
348
437
|
* its retry has to keep polling, until the server answers for itself.
|
|
349
438
|
*/
|
|
350
439
|
function neverCameBack(held: HeldRun, elapsedSeconds: number): DeriveResult {
|
|
351
440
|
return {
|
|
352
|
-
surface:
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
elapsedSeconds,
|
|
360
|
-
),
|
|
361
|
-
overlay: {
|
|
362
|
-
kind: "neverCameBack",
|
|
363
|
-
attemptedVersion: held.attemptedVersion,
|
|
364
|
-
previousVersion: held.previousVersion,
|
|
365
|
-
elapsedSeconds,
|
|
366
|
-
logsCommand: FALLBACK_LOGS_COMMAND,
|
|
367
|
-
},
|
|
368
|
-
},
|
|
441
|
+
surface: neverCameBackSurface(
|
|
442
|
+
held.runId,
|
|
443
|
+
held.previousVersion,
|
|
444
|
+
held.attemptedVersion,
|
|
445
|
+
elapsedSeconds,
|
|
446
|
+
FALLBACK_LOGS_COMMAND,
|
|
447
|
+
),
|
|
369
448
|
releaseHeld: false,
|
|
370
449
|
};
|
|
371
450
|
}
|
|
@@ -408,22 +487,15 @@ function deriveHeld(
|
|
|
408
487
|
if (elapsedSeconds > budgetLimitSeconds()) {
|
|
409
488
|
return neverCameBack(held, elapsedSeconds);
|
|
410
489
|
}
|
|
411
|
-
return
|
|
412
|
-
|
|
490
|
+
return {
|
|
491
|
+
surface: reconnectingSurface(
|
|
413
492
|
held.runId,
|
|
414
493
|
held.previousVersion,
|
|
415
494
|
held.attemptedVersion,
|
|
416
|
-
"reconnecting",
|
|
417
495
|
elapsedSeconds,
|
|
418
496
|
),
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
target: held.attemptedVersion,
|
|
422
|
-
phase: "reconnecting",
|
|
423
|
-
elapsedSeconds,
|
|
424
|
-
},
|
|
425
|
-
false,
|
|
426
|
-
);
|
|
497
|
+
releaseHeld: false,
|
|
498
|
+
};
|
|
427
499
|
}
|
|
428
500
|
|
|
429
501
|
// Nothing has come back yet. Silence is not a phase, so the surface waits
|
|
@@ -474,10 +546,46 @@ function deriveHeld(
|
|
|
474
546
|
}
|
|
475
547
|
|
|
476
548
|
function displayFromData(input: DeriveInput): UpdateSurface {
|
|
477
|
-
const {
|
|
478
|
-
|
|
549
|
+
const {
|
|
550
|
+
data,
|
|
551
|
+
isError,
|
|
552
|
+
dismissedRunId,
|
|
553
|
+
checkPress,
|
|
554
|
+
checkFailure,
|
|
555
|
+
sighting,
|
|
556
|
+
now,
|
|
557
|
+
} = input;
|
|
479
558
|
|
|
480
559
|
if (isError) {
|
|
560
|
+
const stopped = runInFlight(data, dismissedRunId);
|
|
561
|
+
// A tab that never pressed install is in the same restart as the one that
|
|
562
|
+
// did: the run the server last reported is still going, and the server
|
|
563
|
+
// stopping is how it goes. The wait runs from when this tab saw the run,
|
|
564
|
+
// never from the `startedAt` the box wrote — same budget, on a clock the
|
|
565
|
+
// tab owns. A run in flight this tab has no sighting of has only just
|
|
566
|
+
// arrived, so the wait starts here.
|
|
567
|
+
if (stopped !== null) {
|
|
568
|
+
const observedAt =
|
|
569
|
+
sighting !== null && sighting.runId === stopped.runId
|
|
570
|
+
? sighting.observedAt
|
|
571
|
+
: now;
|
|
572
|
+
const elapsedSeconds = elapsedSince(observedAt, now);
|
|
573
|
+
if (elapsedSeconds > budgetLimitSeconds()) {
|
|
574
|
+
return neverCameBackSurface(
|
|
575
|
+
stopped.runId,
|
|
576
|
+
stopped.fromVersion,
|
|
577
|
+
stopped.targetVersion,
|
|
578
|
+
elapsedSeconds,
|
|
579
|
+
stopped.logCommand,
|
|
580
|
+
);
|
|
581
|
+
}
|
|
582
|
+
return reconnectingSurface(
|
|
583
|
+
stopped.runId,
|
|
584
|
+
stopped.fromVersion,
|
|
585
|
+
stopped.targetVersion,
|
|
586
|
+
elapsedSeconds,
|
|
587
|
+
);
|
|
588
|
+
}
|
|
481
589
|
return {
|
|
482
590
|
status: "ready",
|
|
483
591
|
section: {
|