Skip to content

Commit 735276b

Browse files
patrozagithub-actions[bot]
authored andcommitted
fix(client-runtime): re-read network status when the app resumes (upstream pingdotgg#4528)
Imported from pingdotgg#4528
1 parent d46ddeb commit 735276b

7 files changed

Lines changed: 521 additions & 251 deletions

File tree

Lines changed: 298 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,298 @@
1+
import { describe, expect, it } from "@effect/vitest";
2+
import * as Deferred from "effect/Deferred";
3+
import * as Effect from "effect/Effect";
4+
import * as Ref from "effect/Ref";
5+
import * as Stream from "effect/Stream";
6+
import * as SubscriptionRef from "effect/SubscriptionRef";
7+
8+
import * as Connectivity from "./connectivity.ts";
9+
import type { NetworkStatus } from "./model.ts";
10+
import * as ConnectionWakeups from "./wakeups.ts";
11+
12+
const makeHarness = Effect.fn("TestConnectivityHarness.make")(function* (options?: {
13+
readonly status?: Effect.Effect<NetworkStatus>;
14+
readonly initialStatus?: NetworkStatus;
15+
}) {
16+
const liveStatus = yield* Ref.make<NetworkStatus>(options?.initialStatus ?? "online");
17+
const reported = yield* SubscriptionRef.make<{
18+
readonly sequence: number;
19+
readonly status: NetworkStatus;
20+
}>({ sequence: 0, status: options?.initialStatus ?? "online" });
21+
const wakeups = yield* SubscriptionRef.make(0);
22+
const applied = yield* Ref.make<ReadonlyArray<NetworkStatus>>([]);
23+
24+
const connectivity = Connectivity.Connectivity.of({
25+
status: options?.status ?? Ref.get(liveStatus),
26+
changes: SubscriptionRef.changes(reported).pipe(
27+
Stream.drop(1),
28+
Stream.map((event) => event.status),
29+
),
30+
});
31+
32+
const wakeupService = ConnectionWakeups.ConnectionWakeups.of({
33+
changes: SubscriptionRef.changes(wakeups).pipe(
34+
Stream.drop(1),
35+
Stream.map(() => "application-active" as const),
36+
),
37+
});
38+
39+
return {
40+
connectivity,
41+
wakeups: wakeupService,
42+
applied,
43+
setLiveStatus: (status: NetworkStatus) => Ref.set(liveStatus, status),
44+
// Emits a listener event, as a platform connectivity listener would.
45+
report: (status: NetworkStatus) =>
46+
Ref.set(liveStatus, status).pipe(
47+
Effect.andThen(
48+
SubscriptionRef.update(reported, (event) => ({
49+
sequence: event.sequence + 1,
50+
status,
51+
})),
52+
),
53+
),
54+
resume: SubscriptionRef.update(wakeups, (count) => count + 1),
55+
apply: (status: NetworkStatus) => Ref.update(applied, (statuses) => [...statuses, status]),
56+
};
57+
});
58+
59+
// The forked listeners subscribe after `followNetworkStatus` returns, so repeat
60+
// the trigger until its effect is observed rather than racing the first one.
61+
const untilApplied = Effect.fn("TestConnectivityHarness.untilApplied")(function* (
62+
trigger: Effect.Effect<unknown>,
63+
applied: Ref.Ref<ReadonlyArray<NetworkStatus>>,
64+
expected: number,
65+
) {
66+
for (let attempt = 0; attempt < 100; attempt += 1) {
67+
yield* trigger;
68+
yield* Effect.yieldNow;
69+
if ((yield* Ref.get(applied)).length >= expected) {
70+
return yield* Ref.get(applied);
71+
}
72+
}
73+
return yield* Effect.die(new Error("The expected network status was never applied."));
74+
});
75+
76+
describe("followNetworkStatus", () => {
77+
it.effect("applies statuses reported by the platform listener", () =>
78+
Effect.scoped(
79+
Effect.gen(function* () {
80+
const harness = yield* makeHarness();
81+
yield* Connectivity.followNetworkStatus({
82+
connectivity: harness.connectivity,
83+
wakeups: harness.wakeups,
84+
apply: harness.apply,
85+
});
86+
87+
const applied = yield* untilApplied(harness.report("offline"), harness.applied, 1);
88+
expect(applied).toEqual(["offline"]);
89+
}),
90+
),
91+
);
92+
93+
it.effect("applies a resumed read when the listener missed a transition", () =>
94+
Effect.scoped(
95+
Effect.gen(function* () {
96+
const harness = yield* makeHarness({ initialStatus: "offline" });
97+
yield* Connectivity.followNetworkStatus({
98+
connectivity: harness.connectivity,
99+
wakeups: harness.wakeups,
100+
apply: harness.apply,
101+
});
102+
103+
// The device came back online while suspended and the listener never
104+
// reported the transition.
105+
yield* harness.setLiveStatus("online");
106+
const applied = yield* untilApplied(harness.resume, harness.applied, 1);
107+
expect(applied).toEqual(["online"]);
108+
}),
109+
),
110+
);
111+
112+
it.effect("discards a resumed read that a repeated report raced", () =>
113+
Effect.scoped(
114+
Effect.gen(function* () {
115+
const readStarted = yield* Deferred.make<void>();
116+
const releaseRead = yield* Deferred.make<void>();
117+
const harness = yield* makeHarness({
118+
initialStatus: "online",
119+
// The resume samples a brief opposite state.
120+
status: Deferred.succeed(readStarted, undefined).pipe(
121+
Effect.andThen(Deferred.await(releaseRead)),
122+
Effect.as("offline" as const),
123+
),
124+
});
125+
yield* Connectivity.followNetworkStatus({
126+
connectivity: harness.connectivity,
127+
wakeups: harness.wakeups,
128+
apply: harness.apply,
129+
});
130+
131+
// Apply "online" first so the listener's later repeat of it is genuinely
132+
// redundant rather than a new status.
133+
yield* untilApplied(harness.report("online"), harness.applied, 1);
134+
yield* harness.resume.pipe(
135+
Effect.andThen(Effect.yieldNow),
136+
Effect.repeat({ until: () => Deferred.isDone(readStarted) }),
137+
);
138+
139+
// The repeat carries no new status, but it proves the listener spoke
140+
// after this read began, so the read is stale even though the status it
141+
// returns differs. Applying it would strand consumers on "offline" while
142+
// the platform reports "online" — the very failure this helper exists to
143+
// prevent.
144+
yield* harness.report("online");
145+
yield* Effect.yieldNow;
146+
yield* Deferred.succeed(releaseRead, undefined);
147+
yield* Effect.yieldNow;
148+
yield* Effect.yieldNow;
149+
150+
expect(yield* Ref.get(harness.applied)).toEqual(["online"]);
151+
}),
152+
),
153+
);
154+
155+
it.effect("still deduplicates a repeated report before it reaches consumers", () =>
156+
Effect.scoped(
157+
Effect.gen(function* () {
158+
const harness = yield* makeHarness({ initialStatus: "online" });
159+
yield* Connectivity.followNetworkStatus({
160+
connectivity: harness.connectivity,
161+
wakeups: harness.wakeups,
162+
apply: harness.apply,
163+
});
164+
165+
yield* untilApplied(harness.report("offline"), harness.applied, 1);
166+
// Counting the repeat for staleness must not turn it into a redundant
167+
// consumer update.
168+
yield* harness.report("offline");
169+
yield* Effect.yieldNow;
170+
yield* Effect.yieldNow;
171+
expect(yield* Ref.get(harness.applied)).toEqual(["offline"]);
172+
173+
// A genuine transition after the repeat still lands.
174+
yield* untilApplied(harness.report("online"), harness.applied, 2);
175+
expect(yield* Ref.get(harness.applied)).toEqual(["offline", "online"]);
176+
}),
177+
),
178+
);
179+
180+
it.effect("does not start a second status read while one is in flight", () =>
181+
Effect.scoped(
182+
Effect.gen(function* () {
183+
const firstRead = yield* Deferred.make<NetworkStatus>();
184+
const readCount = yield* Ref.make(0);
185+
const harness = yield* makeHarness({
186+
initialStatus: "unknown",
187+
status: Ref.updateAndGet(readCount, (count) => count + 1).pipe(
188+
Effect.andThen(Deferred.await(firstRead)),
189+
),
190+
});
191+
yield* Connectivity.followNetworkStatus({
192+
connectivity: harness.connectivity,
193+
wakeups: harness.wakeups,
194+
apply: harness.apply,
195+
});
196+
197+
yield* harness.resume.pipe(
198+
Effect.andThen(Effect.yieldNow),
199+
Effect.repeat({
200+
until: () => Ref.get(readCount).pipe(Effect.map((count) => count >= 1)),
201+
}),
202+
);
203+
204+
// Resumes are consumed sequentially, so further resumes cannot start a
205+
// read that races the one already in flight. Overlapping snapshots
206+
// therefore cannot apply out of order.
207+
yield* harness.resume;
208+
yield* harness.resume;
209+
yield* Effect.yieldNow;
210+
expect(yield* Ref.get(readCount)).toBe(1);
211+
212+
yield* Deferred.succeed(firstRead, "online");
213+
yield* Effect.yieldNow;
214+
expect(yield* Ref.get(harness.applied)).toEqual(["online"]);
215+
}),
216+
),
217+
);
218+
219+
it.effect("discards a resumed read that a newer reported change superseded", () =>
220+
Effect.scoped(
221+
Effect.gen(function* () {
222+
const readStarted = yield* Deferred.make<void>();
223+
const releaseRead = yield* Deferred.make<void>();
224+
const harness = yield* makeHarness({
225+
initialStatus: "offline",
226+
status: Deferred.succeed(readStarted, undefined).pipe(
227+
Effect.andThen(Deferred.await(releaseRead)),
228+
Effect.as("online" as const),
229+
),
230+
});
231+
yield* Connectivity.followNetworkStatus({
232+
connectivity: harness.connectivity,
233+
wakeups: harness.wakeups,
234+
apply: harness.apply,
235+
});
236+
237+
// Resume, and hold the status read open so the listener can report a
238+
// newer transition while that read is still in flight.
239+
yield* harness.resume.pipe(
240+
Effect.andThen(Effect.yieldNow),
241+
Effect.repeat({ until: () => Deferred.isDone(readStarted) }),
242+
);
243+
yield* untilApplied(harness.report("offline"), harness.applied, 1);
244+
yield* Deferred.succeed(releaseRead, undefined);
245+
yield* Effect.yieldNow;
246+
247+
// The stale "online" snapshot must not land on top of the newer event.
248+
expect(yield* Ref.get(harness.applied)).toEqual(["offline"]);
249+
}),
250+
),
251+
);
252+
253+
it.effect("keeps a reported change from interleaving with a resumed apply", () =>
254+
Effect.scoped(
255+
Effect.gen(function* () {
256+
const applyStarted = yield* Deferred.make<void>();
257+
const releaseApply = yield* Deferred.make<void>();
258+
const harness = yield* makeHarness({ initialStatus: "offline" });
259+
// Holds the resumed apply open once it begins, so a reported change has
260+
// a window to interleave between the guard and its apply.
261+
const gatedApply = (status: NetworkStatus) =>
262+
status === "online"
263+
? Deferred.succeed(applyStarted, undefined).pipe(
264+
Effect.andThen(Deferred.await(releaseApply)),
265+
Effect.andThen(harness.apply(status)),
266+
)
267+
: harness.apply(status);
268+
269+
yield* Connectivity.followNetworkStatus({
270+
connectivity: harness.connectivity,
271+
wakeups: harness.wakeups,
272+
apply: gatedApply,
273+
});
274+
275+
yield* harness.setLiveStatus("online");
276+
yield* harness.resume.pipe(
277+
Effect.andThen(Effect.yieldNow),
278+
Effect.repeat({ until: () => Deferred.isDone(applyStarted) }),
279+
);
280+
281+
// The listener reports a newer transition mid-apply.
282+
yield* harness.report("offline");
283+
yield* Effect.yieldNow;
284+
yield* Deferred.succeed(releaseApply, undefined);
285+
yield* Effect.yieldNow.pipe(
286+
Effect.repeat({
287+
until: () =>
288+
Ref.get(harness.applied).pipe(Effect.map((statuses) => statuses.length >= 2)),
289+
}),
290+
);
291+
292+
// The reported change has to land last; applying it before the resumed
293+
// status finished would leave the stale "online" as the final word.
294+
expect(yield* Ref.get(harness.applied)).toEqual(["online", "offline"]);
295+
}),
296+
),
297+
);
298+
});

packages/client-runtime/src/connection/connectivity.ts

Lines changed: 82 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
import * as Context from "effect/Context";
2-
import type * as Effect from "effect/Effect";
2+
import * as Effect from "effect/Effect";
33
import * as Layer from "effect/Layer";
4-
import type * as Stream from "effect/Stream";
4+
import * as Option from "effect/Option";
5+
import * as Ref from "effect/Ref";
6+
import * as Semaphore from "effect/Semaphore";
7+
import * as Stream from "effect/Stream";
58

69
import type { NetworkStatus } from "./model.ts";
10+
import * as ConnectionWakeups from "./wakeups.ts";
711

812
export class Connectivity extends Context.Service<
913
Connectivity,
@@ -17,3 +21,79 @@ export const make = (service: Connectivity["Service"]) => Connectivity.of(servic
1721

1822
export const layer = (service: Connectivity["Service"]) =>
1923
Layer.succeed(Connectivity, make(service));
24+
25+
/**
26+
* Applies every reported connectivity change, plus a freshly read status each
27+
* time the application resumes.
28+
*
29+
* Platform listeners drop transitions while an app is suspended, so a consumer
30+
* that follows `changes` alone keeps a stale status until the next real
31+
* transition — which left mobile stranded on "offline" until it was restarted.
32+
* Reading the status is asynchronous, so a read that started before a newer
33+
* reported change is discarded instead of applied over it.
34+
*/
35+
export const followNetworkStatus = Effect.fnUntraced(function* (options: {
36+
readonly connectivity: Connectivity["Service"];
37+
readonly wakeups: ConnectionWakeups.ConnectionWakeups["Service"];
38+
readonly apply: (status: NetworkStatus) => Effect.Effect<void>;
39+
}) {
40+
// Counts every report the listener delivers, including one that repeats the
41+
// status already in effect. Such a repeat carries no new status, but it does
42+
// prove the listener spoke more recently than a resume read still in flight,
43+
// so it has to invalidate that read: otherwise a snapshot taken during a brief
44+
// opposite state would land afterwards and overwrite the real status.
45+
const reportCount = yield* Ref.make(0);
46+
// Tracks what was last handed to `options.apply` purely to keep repeats from
47+
// reaching consumers. Deduplication is deliberately kept separate from the
48+
// staleness guard above.
49+
const appliedStatus = yield* Ref.make<Option.Option<NetworkStatus>>(Option.none());
50+
// Counting a report and applying it has to be indivisible with respect to the
51+
// resume branch's guard. Otherwise a change landing between that guard and its
52+
// apply would be overwritten by the older read.
53+
const applyLock = yield* Semaphore.make(1);
54+
55+
const applyStatus = Effect.fnUntraced(function* (status: NetworkStatus) {
56+
const changed = yield* Ref.modify(appliedStatus, (current) =>
57+
Option.isSome(current) && current.value === status
58+
? ([false, current] as const)
59+
: ([true, Option.some(status)] as const),
60+
);
61+
if (changed) {
62+
yield* options.apply(status);
63+
}
64+
});
65+
66+
yield* options.connectivity.changes.pipe(
67+
Stream.runForEach((status) =>
68+
applyLock.withPermits(1)(
69+
Ref.update(reportCount, (count) => count + 1).pipe(Effect.andThen(applyStatus(status))),
70+
),
71+
),
72+
// Subscribe before returning so a transition reported while this is still
73+
// being set up is not dropped.
74+
Effect.forkScoped({ startImmediately: true }),
75+
);
76+
77+
yield* options.wakeups.changes.pipe(
78+
Stream.runForEach((reason) =>
79+
reason === "application-active"
80+
? Effect.gen(function* () {
81+
// `runForEach` is sequential, so resume reads cannot overlap: a
82+
// second resume does not start a read until this one has applied.
83+
const startedAt = yield* Ref.get(reportCount);
84+
const status = yield* options.connectivity.status;
85+
yield* applyLock.withPermits(1)(
86+
Effect.gen(function* () {
87+
// Re-read under the permit: any report that arrived while the
88+
// read was in flight is newer, so this result is stale.
89+
if ((yield* Ref.get(reportCount)) === startedAt) {
90+
yield* applyStatus(status);
91+
}
92+
}),
93+
);
94+
})
95+
: Effect.void,
96+
),
97+
Effect.forkScoped({ startImmediately: true }),
98+
);
99+
});

0 commit comments

Comments
 (0)