@tapflowio/ios-agent 0.19.0 → 0.20.1
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/bin/TapflowNetFilter.app/Contents/CodeResources +0 -0
- package/bin/TapflowNetFilter.app/Contents/Info.plist +48 -0
- package/bin/TapflowNetFilter.app/Contents/Library/SystemExtensions/dev.tapflow.netfilter.ext.systemextension/Contents/Info.plist +58 -0
- package/bin/TapflowNetFilter.app/Contents/Library/SystemExtensions/dev.tapflow.netfilter.ext.systemextension/Contents/MacOS/dev.tapflow.netfilter.ext +0 -0
- package/bin/TapflowNetFilter.app/Contents/Library/SystemExtensions/dev.tapflow.netfilter.ext.systemextension/Contents/_CodeSignature/CodeResources +123 -0
- package/bin/TapflowNetFilter.app/Contents/Library/SystemExtensions/dev.tapflow.netfilter.ext.systemextension/Contents/embedded.provisionprofile +0 -0
- package/bin/TapflowNetFilter.app/Contents/MacOS/TapflowNetFilter +0 -0
- package/bin/TapflowNetFilter.app/Contents/PkgInfo +1 -0
- package/bin/TapflowNetFilter.app/Contents/_CodeSignature/CodeResources +151 -0
- package/bin/TapflowNetFilter.app/Contents/embedded.provisionprofile +0 -0
- package/bin/libtapflow-nethook.dylib +0 -0
- package/dist/IOSAgent.d.ts +131 -3
- package/dist/IOSAgent.d.ts.map +1 -1
- package/dist/IOSAgent.js +312 -20
- package/dist/IOSAgent.js.map +1 -1
- package/dist/SimctlWrapper.d.ts +24 -0
- package/dist/SimctlWrapper.d.ts.map +1 -1
- package/dist/SimctlWrapper.js +32 -0
- package/dist/SimctlWrapper.js.map +1 -1
- package/dist/SimulatorNetwork.d.ts +414 -0
- package/dist/SimulatorNetwork.d.ts.map +1 -0
- package/dist/SimulatorNetwork.js +996 -0
- package/dist/SimulatorNetwork.js.map +1 -0
- package/package.json +10 -8
|
@@ -0,0 +1,996 @@
|
|
|
1
|
+
import { execFile } from 'child_process';
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'fs';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
import { promisify } from 'util';
|
|
5
|
+
const execFileAsync = promisify(execFile);
|
|
6
|
+
const NETHOOK_DYLIB = join(import.meta.dirname, '..', 'bin', 'libtapflow-nethook.dylib');
|
|
7
|
+
/**
|
|
8
|
+
* A regular file, not merely a path that resolves.
|
|
9
|
+
*
|
|
10
|
+
* `existsSync` answers true for a directory, and dyld cannot inject one — so the check that exists to
|
|
11
|
+
* say "the library is not here" would have passed and let `state()` fall through to the verdict,
|
|
12
|
+
* reporting `awaiting-app` about an install that can never work. `statSync` follows symlinks, which is
|
|
13
|
+
* right: a link to a real dylib is one.
|
|
14
|
+
*/
|
|
15
|
+
function isFile(p) {
|
|
16
|
+
try {
|
|
17
|
+
return statSync(p).isFile();
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
const DEFAULT_HOST_BINARY = '/Applications/TapflowNetFilter.app/Contents/MacOS/TapflowNetFilter';
|
|
24
|
+
/** How long the filter host gets. It activates a system extension, which is a few hundred ms when
|
|
25
|
+
* nothing is wrong and unbounded when a user is being asked to approve something. */
|
|
26
|
+
const FILTER_HOST_TIMEOUT_MS = 15_000;
|
|
27
|
+
/**
|
|
28
|
+
* How long a confirmation gets. **This is the mechanism, not a backstop.**
|
|
29
|
+
*
|
|
30
|
+
* A confirmation made while the provider is dead does not fail — it *blocks*. Measured 3/3: the call
|
|
31
|
+
* ran to the caller's own deadline with neither the invalidation nor the interruption handler firing,
|
|
32
|
+
* because launchd holds the mach name while the process is away. So this number is what decides
|
|
33
|
+
* `filter-unavailable` in the commonest failure there is, and the window it has to decide inside is
|
|
34
|
+
* not rare: a provider killed and restarted by launchd is gone for about 5.8 seconds (measured; one
|
|
35
|
+
* run in five took 21.3).
|
|
36
|
+
*
|
|
37
|
+
* One second: about thirty times the measured worst case of a healthy round trip (host binary launch
|
|
38
|
+
* 34ms, XPC 0.26–0.74ms, propagation under 55ms), and an eighth of the dashboard's 8s request
|
|
39
|
+
* deadline, so a refusal arrives as a refusal rather than as a request that timed out.
|
|
40
|
+
*
|
|
41
|
+
* It is also how long the operation queue is held when things go wrong, which is the cost side: a
|
|
42
|
+
* second device's toggle waits behind it. That is accepted rather than overlooked, and the
|
|
43
|
+
* alternative was tried and reviewed out — confirming outside the queue lets the write and its
|
|
44
|
+
* confirmation belong to different rules, which produced two ways of applying layers 2 and 3 over a
|
|
45
|
+
* kernel that was not enforcing. `serialize` has the sequences.
|
|
46
|
+
*/
|
|
47
|
+
const FILTER_CONFIRM_TIMEOUT_MS = 1_000;
|
|
48
|
+
/**
|
|
49
|
+
* How long the **file** fallback gets, once the ask above has already failed.
|
|
50
|
+
*
|
|
51
|
+
* Only reached when the XPC channel is gone, and after a system-extension replace it is: the retired
|
|
52
|
+
* extension sits `[terminated waiting to uninstall on reboot]` holding the mach name, the new
|
|
53
|
+
* provider's `NSXPCListener.resume()` fails with `Operation not permitted`, and `--confirm` answers
|
|
54
|
+
* `no listener` in 9ms while the filter is genuinely enforcing. Measured 2026-09-03. The listener is
|
|
55
|
+
* vended once per process and the provider survives `--off`/`--install`, so that failure lasts as
|
|
56
|
+
* long as the provider does.
|
|
57
|
+
*
|
|
58
|
+
* Three seconds is three of the provider's enforcing pulses, the same tolerance `checkLivenessLocked`
|
|
59
|
+
* gives a rule change that has not been published yet. It covers the forced publish a rule change
|
|
60
|
+
* triggers (one 1s tick plus the timer's 250ms leeway).
|
|
61
|
+
*
|
|
62
|
+
* **What it does not cover, on purpose**: a provider old enough to lack that forced publish, going
|
|
63
|
+
* back *online* on a Mac with no traffic at all — that direction waits out `pulseSeconds(false)`,
|
|
64
|
+
* 4.75s. Sizing this past that would hold the operation queue for six seconds on every failed
|
|
65
|
+
* toggle. The case is no worse than it is today, where the same Mac has no answer at all.
|
|
66
|
+
*/
|
|
67
|
+
const FILTER_FILE_CONFIRM_DEADLINE_MS = 3_000;
|
|
68
|
+
/** Cheap enough to be indistinguishable from the file's own write, and coarse enough not to spin. */
|
|
69
|
+
const FILTER_FILE_POLL_MS = 100;
|
|
70
|
+
/** Where the provider writes what it is enforcing. Both are tried: the first is where it lands on a
|
|
71
|
+
* healthy Mac, the second is the fallback it uses when that directory cannot be written. */
|
|
72
|
+
const FILTER_STATE_FILES = [
|
|
73
|
+
'/Library/Application Support/tapflow/tapflow-netfilter-state.json',
|
|
74
|
+
'/tmp/tapflow-netfilter-state.json',
|
|
75
|
+
];
|
|
76
|
+
/** How often liveness is checked while anything is offline. Matches the provider's fast pulse — it
|
|
77
|
+
* writes every second while its rule is non-empty — so a stopped heartbeat is noticed in about the
|
|
78
|
+
* time it takes for three of them to go missing. */
|
|
79
|
+
const LIVENESS_INTERVAL_MS = 1_000;
|
|
80
|
+
/**
|
|
81
|
+
* How long after a launch an absent verdict is still ordinary (#629).
|
|
82
|
+
*
|
|
83
|
+
* **Derived from the dylib rather than picked.** Its verdict is written from a
|
|
84
|
+
* `__attribute__((constructor))`, which reads as "milliseconds" and is wrong: the constructor runs
|
|
85
|
+
* `tf_self_check()` first, and that waits on a semaphore for up to **3 seconds** for a path update
|
|
86
|
+
* (`network-hook.m`). So a perfectly healthy install produces no verdict for three seconds after the
|
|
87
|
+
* process starts, plus whatever the launch itself took.
|
|
88
|
+
*
|
|
89
|
+
* 10s is that plus room for a loaded Mac starting a large app. Generous on purpose: past this the
|
|
90
|
+
* answer changes from "waiting" to "this cannot work", and being early would put that verdict on an
|
|
91
|
+
* install that was about to succeed. The cost of being late is that a tester sees `awaiting-app` for a
|
|
92
|
+
* few seconds longer — which is the true answer for those seconds.
|
|
93
|
+
*/
|
|
94
|
+
export const LAUNCH_VERDICT_DEADLINE_MS = 10_000;
|
|
95
|
+
/** What the provider pulses at **while it is enforcing something** (`Provider.swift`, `pulseSeconds`).
|
|
96
|
+
* Held here as well because a file written before the rule changed declares the *idle* rate, so the
|
|
97
|
+
* rate to expect next cannot always be read out of the file. Change one and change the other. */
|
|
98
|
+
const ENFORCING_PULSE_SECONDS = 1;
|
|
99
|
+
/**
|
|
100
|
+
* The non-negative counts in an untrusted object, entry by entry.
|
|
101
|
+
*
|
|
102
|
+
* Anything else is dropped rather than failing the whole read — see `droppedByDevice`. This file is
|
|
103
|
+
* written by a root-owned process on the other side of a version boundary, so a value that is not a
|
|
104
|
+
* count is a thing to discard, not a reason to disbelieve the rest.
|
|
105
|
+
*
|
|
106
|
+
* **`Number.isInteger`, because these are flows** and `dropped 0.5 flow(s)` is a sentence no reader
|
|
107
|
+
* should be handed. **The array guard and the `>= 0` do not change today's output** — an array index
|
|
108
|
+
* cannot be a udid, and a negative would fail the `> 0` test at the only place this is consumed. They
|
|
109
|
+
* are here for the second consumer, and that is stated rather than left to look load-bearing.
|
|
110
|
+
*/
|
|
111
|
+
function numbersIn(raw) {
|
|
112
|
+
if (typeof raw !== 'object' || raw === null || Array.isArray(raw))
|
|
113
|
+
return {};
|
|
114
|
+
const out = {};
|
|
115
|
+
for (const [k, v] of Object.entries(raw)) {
|
|
116
|
+
if (typeof v === 'number' && Number.isInteger(v) && v >= 0)
|
|
117
|
+
out[k] = v;
|
|
118
|
+
}
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
export class SimulatorNetwork {
|
|
122
|
+
simctl;
|
|
123
|
+
hostBinary;
|
|
124
|
+
conditionDir;
|
|
125
|
+
verdictDir;
|
|
126
|
+
dylib;
|
|
127
|
+
/** Every simulator currently offline. The filter takes the whole set on each call — it has no
|
|
128
|
+
* add/remove — so this is the authority for what the rule should say, not a cache of it. */
|
|
129
|
+
offline = new Set();
|
|
130
|
+
/** Devices whose injection is actually in place. Separates "nothing was delivered" from "delivered,
|
|
131
|
+
* no app has run under it yet" — two states with different remedies, and a reason each. */
|
|
132
|
+
armed = new Set();
|
|
133
|
+
/** Serialises **whole operations**, not just the host run. See `serialize`. */
|
|
134
|
+
filterQueue = Promise.resolve();
|
|
135
|
+
/** Set while a liveness check is queued, so a slow toggle does not let ticks pile up behind it. */
|
|
136
|
+
livenessQueued = false;
|
|
137
|
+
/** What layer 1 was last found doing, per device. See `FilterVerdict` for why it is remembered. */
|
|
138
|
+
filterVerdict = new Map();
|
|
139
|
+
/** When each device's offline rule was confirmed, in whole seconds. Read by `checkLiveness` to tell
|
|
140
|
+
* a file that disagrees from one that is simply older than the write. */
|
|
141
|
+
offlineSince = new Map();
|
|
142
|
+
/**
|
|
143
|
+
* The drop count last reported for each offline device (#654).
|
|
144
|
+
*
|
|
145
|
+
* **Two things need it.** The liveness check runs every second, so a device with any drops at all
|
|
146
|
+
* would otherwise repeat the same line 600 times in a ten-minute session — into the stream where
|
|
147
|
+
* the enforcement-lost report a person is actually looking for arrives. And the provider's count is
|
|
148
|
+
* per episode but the *file* is a snapshot: only an increase since the last read is news.
|
|
149
|
+
*
|
|
150
|
+
* Cleared wherever `offlineSince` is, so a device that goes offline again starts from nothing —
|
|
151
|
+
* an absent entry reads as zero, and the provider prunes its own counts per episode too, so both
|
|
152
|
+
* ends forget together.
|
|
153
|
+
*/
|
|
154
|
+
dropsReported = new Map();
|
|
155
|
+
/**
|
|
156
|
+
* When a launch was last issued for each device (#629).
|
|
157
|
+
*
|
|
158
|
+
* A timestamp rather than a timer: `state()` is synchronous and called from several paths, and there
|
|
159
|
+
* is nothing to cancel — a second launch overwrites this, and a verdict arriving makes it
|
|
160
|
+
* irrelevant. A timer would have to be cancelled on both.
|
|
161
|
+
*/
|
|
162
|
+
launchedAt = new Map();
|
|
163
|
+
/** The pid of the last launch that actually started a process, per device. See `markLaunched`. */
|
|
164
|
+
launchedPid = new Map();
|
|
165
|
+
/** Runs only while something is offline. See `updateLiveness`. */
|
|
166
|
+
liveness;
|
|
167
|
+
/** Set by `dispose`. Without it, work that was already in flight puts the interval back: both
|
|
168
|
+
* `setOffline` and `checkLiveness` call `updateLiveness()` *after* their awaits, so a dispose
|
|
169
|
+
* landing in between was undone by whichever of them resumed next.
|
|
170
|
+
*
|
|
171
|
+
* **No test reaches this flag, and one that appeared to was deleted rather than kept.** The
|
|
172
|
+
* scenario it needs — a dispose landing mid-operation, then a state file that makes the watcher
|
|
173
|
+
* report on its next tick — did not report even with the flag removed *and* `clearInterval` taken
|
|
174
|
+
* out of `dispose`, so the assertion was green against every mutation of the thing it named.
|
|
175
|
+
* Observing the watcher stop needs a seam this class does not have (#664). The flag stays because
|
|
176
|
+
* it is correct and costs one comparison; what it does not have is coverage, and saying so here is
|
|
177
|
+
* cheaper than a test that says otherwise. */
|
|
178
|
+
disposed = false;
|
|
179
|
+
stateFiles;
|
|
180
|
+
confirmDeadlineMs;
|
|
181
|
+
enforcementLost;
|
|
182
|
+
livenessIntervalMs;
|
|
183
|
+
constructor(simctl, opts = {}) {
|
|
184
|
+
this.simctl = simctl;
|
|
185
|
+
this.hostBinary = opts.filterHostBinary ?? DEFAULT_HOST_BINARY;
|
|
186
|
+
this.conditionDir = opts.conditionDir ?? '/tmp';
|
|
187
|
+
this.verdictDir = opts.verdictDir ?? '/tmp';
|
|
188
|
+
this.dylib = opts.nethookDylib ?? NETHOOK_DYLIB;
|
|
189
|
+
this.stateFiles = opts.filterStateFiles ?? FILTER_STATE_FILES;
|
|
190
|
+
this.confirmDeadlineMs = opts.filterConfirmDeadlineMs ?? FILTER_FILE_CONFIRM_DEADLINE_MS;
|
|
191
|
+
this.enforcementLost = opts.onEnforcementLost ?? (() => { });
|
|
192
|
+
this.livenessIntervalMs = opts.livenessIntervalMs ?? LIVENESS_INTERVAL_MS;
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Put the injection in place for a device that has just booted.
|
|
196
|
+
*
|
|
197
|
+
* **Clearing comes first, and it is not tidiness.** The condition file and the verdict both outlive
|
|
198
|
+
* the simulator that wrote them — they are on the host, keyed only by udid — so a device that boots
|
|
199
|
+
* into a leftover condition file is offline before anyone asked, and a leftover verdict answers for
|
|
200
|
+
* hooks that belong to a process which no longer exists.
|
|
201
|
+
*
|
|
202
|
+
* The library is armed here and the target app is not, because the target is not known yet. Until
|
|
203
|
+
* `target()` names one the dylib loads into every process in the simulator and hooks none of them,
|
|
204
|
+
* which is its designed default.
|
|
205
|
+
*/
|
|
206
|
+
async arm(udid) {
|
|
207
|
+
return this.serialize(() => this.armLocked(udid));
|
|
208
|
+
}
|
|
209
|
+
async armLocked(udid) {
|
|
210
|
+
this.offline.delete(udid);
|
|
211
|
+
this.armed.delete(udid);
|
|
212
|
+
// **Layer 1's remembered judgment goes too, and leaving it out was a defect two reviewers found
|
|
213
|
+
// independently.** `state()` reads it before every other piece of evidence, and nothing else ever
|
|
214
|
+
// clears it: a `'lost'` set by `checkLiveness` survives the provider coming back, because the
|
|
215
|
+
// device has left `this.offline` and liveness never looks at it again. So a simulator whose
|
|
216
|
+
// enforcement was lost, then rebooted, answered `enforcement-lost` from `device:ready` and from
|
|
217
|
+
// every re-join afterwards — and the dashboard interrupts on that reason rather than re-colouring,
|
|
218
|
+
// so a new tester was told to re-check work belonging to a session that had already ended.
|
|
219
|
+
//
|
|
220
|
+
// This block's own doc says a leftover verdict answers for a process that no longer exists. That
|
|
221
|
+
// was true of the dylib's file and not of this, which is exactly how it was missed.
|
|
222
|
+
this.filterVerdict.delete(udid);
|
|
223
|
+
this.offlineSince.delete(udid);
|
|
224
|
+
this.dropsReported.delete(udid);
|
|
225
|
+
// **With the verdict it belongs to.** A boot is a new process for every app, so a launch recorded
|
|
226
|
+
// before it describes one that no longer exists — and leaving it behind means a freshly booted
|
|
227
|
+
// device reads as `hooks-not-installed` from its first `state()` call, on the strength of an app
|
|
228
|
+
// that ran in the previous session.
|
|
229
|
+
this.launchedAt.delete(udid);
|
|
230
|
+
this.launchedPid.delete(udid);
|
|
231
|
+
this.setCondition(udid, false);
|
|
232
|
+
rmSync(this.verdictPath(udid), { force: true });
|
|
233
|
+
// **The rule is rewritten, not merely forgotten.** Clearing the in-memory set and the condition
|
|
234
|
+
// file used to be the whole of this, which left the one layer that actually stops traffic still
|
|
235
|
+
// naming the device: a simulator toggled offline, shut down and booted again came up with its
|
|
236
|
+
// traffic dead while this class reported it online and steerable, and nothing recovered it short
|
|
237
|
+
// of toggling twice.
|
|
238
|
+
//
|
|
239
|
+
// **Names this udid and only this udid.** The rule is the host's and the set is this process's
|
|
240
|
+
// memory, so a rule left behind by the process before it still has to be cleared — but clearing
|
|
241
|
+
// it by writing this agent's whole set is what erased every other agent's devices. Naming the one
|
|
242
|
+
// that just booted does the same job for the device that is actually being armed.
|
|
243
|
+
await this.runFilterHost({ remove: [udid] });
|
|
244
|
+
// Arm can empty the offline set, and the watcher has to stop with it or tick forever on nothing.
|
|
245
|
+
this.updateLiveness();
|
|
246
|
+
// Recorded only after the call returns. A device whose environment could not be set has had
|
|
247
|
+
// nothing delivered, and saying otherwise would report it as merely waiting for an app — a state
|
|
248
|
+
// whose remedy is to launch one, which would never help.
|
|
249
|
+
await this.simctl.setSimulatorEnv(udid, 'DYLD_INSERT_LIBRARIES', this.dylib);
|
|
250
|
+
this.armed.add(udid);
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Name the app the hooks may touch.
|
|
254
|
+
*
|
|
255
|
+
* **Must be called before the app is launched.** dyld reads the environment when a process starts,
|
|
256
|
+
* so naming the target afterwards arms the *next* launch and leaves the running one unhooked —
|
|
257
|
+
* reporting `available: true` for an app that would never see a path update.
|
|
258
|
+
*
|
|
259
|
+
* **The previous app's verdict goes with it.** A verdict is one process's report that its own hooks
|
|
260
|
+
* took, and that process has exited by the time a second app is launched. Leaving the file behind
|
|
261
|
+
* answered for the new app on the old one's evidence: `available: true` before the new process had
|
|
262
|
+
* written anything, and — if its hooks fail — for as long as it runs. The gap where `state()`
|
|
263
|
+
* answers `awaiting-app` for a launch already in flight is the correct reading of that moment;
|
|
264
|
+
* inheriting a stale `ok` is not.
|
|
265
|
+
*/
|
|
266
|
+
async target(udid, bundleId) {
|
|
267
|
+
rmSync(this.verdictPath(udid), { force: true });
|
|
268
|
+
await this.simctl.setSimulatorEnv(udid, 'TAPFLOW_TARGET_BUNDLE', bundleId);
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* A process started, so a verdict is now owed — `markLaunched` opens the window `state()` measures.
|
|
272
|
+
*
|
|
273
|
+
* **Called after the launch, not before it, and that is a correction.** The mark was first set at
|
|
274
|
+
* the top of `target()`, which put `simctl spawn … launchctl setenv`, `simctl launch` and the app's
|
|
275
|
+
* whole dyld load *inside* the budget — none of it measured — and left the mark standing when the
|
|
276
|
+
* launch failed outright, so a launch that never happened reported the hooks as broken ten seconds
|
|
277
|
+
* later.
|
|
278
|
+
*
|
|
279
|
+
* **Only for a pid that is new.** `simctl launch` is issued without `--terminate-running-process`,
|
|
280
|
+
* so launching a bundle that is already running returns the existing pid and starts nothing. The
|
|
281
|
+
* verdict is written from a dyld constructor — once per process — so nothing will rewrite the file
|
|
282
|
+
* `target()` just deleted, and starting a deadline there would report a permanently dead control
|
|
283
|
+
* over an app whose hooks are live and watching. That case stays what it was before this change,
|
|
284
|
+
* which is wrong but harmless; #692 has the decision it needs.
|
|
285
|
+
*/
|
|
286
|
+
markLaunched(udid, pid) {
|
|
287
|
+
if (pid === null || this.launchedPid.get(udid) === pid)
|
|
288
|
+
return;
|
|
289
|
+
this.launchedPid.set(udid, pid);
|
|
290
|
+
this.launchedAt.set(udid, Date.now());
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* **Layer 1 leads in both directions, and the order is measured rather than chosen.**
|
|
294
|
+
*
|
|
295
|
+
* Going offline, the dylib cuts the app's open sockets the moment the flag file appears. If the
|
|
296
|
+
* filter were not already dropping new flows at that instant, the app would simply reconnect —
|
|
297
|
+
* reproduced exactly that way while stepping the layers separately, and the reconnected socket then
|
|
298
|
+
* survived the rest of the session.
|
|
299
|
+
*
|
|
300
|
+
* Coming back, the filter has to stop dropping before the app is told the path is satisfied, or the
|
|
301
|
+
* first thing it does with the good news is fail.
|
|
302
|
+
*
|
|
303
|
+
* The status bar goes last either way: it reports, so it should not claim a state before the state
|
|
304
|
+
* is true.
|
|
305
|
+
*/
|
|
306
|
+
async setOffline(udid, offline) {
|
|
307
|
+
return this.serialize(() => this.setOfflineLocked(udid, offline));
|
|
308
|
+
}
|
|
309
|
+
/** The body of `setOffline`, running with the queue held. Calls `runFilterHost` directly: going
|
|
310
|
+
* through `serialize` again from in here would wait on a slot this call already owns. */
|
|
311
|
+
async setOfflineLocked(udid, offline) {
|
|
312
|
+
const was = this.offline.has(udid);
|
|
313
|
+
if (offline)
|
|
314
|
+
this.offline.add(udid);
|
|
315
|
+
else
|
|
316
|
+
this.offline.delete(udid);
|
|
317
|
+
const enforced = await this.applyAndConfirm(udid, offline);
|
|
318
|
+
if (!enforced) {
|
|
319
|
+
// **Layer 1 is not enforcing, so layers 2 and 3 do not get applied.** Two of the three work
|
|
320
|
+
// without it and neither blocks traffic: the app would be told its path is unsatisfied and its
|
|
321
|
+
// sockets would be cut, while every request it makes afterwards succeeds. That is a tester
|
|
322
|
+
// signing off offline behaviour they never saw — the exact failure this feature exists to
|
|
323
|
+
// prevent, produced by the feature itself.
|
|
324
|
+
//
|
|
325
|
+
// **Both directions, including coming back online.** A draft carried the online request out
|
|
326
|
+
// anyway, reasoning that a device nothing is enforcing is reachable already and refusing would
|
|
327
|
+
// strand an app believing it is offline. That reasoning was wrong and a test caught it: not
|
|
328
|
+
// being able to *change* the rule is not the same as nothing enforcing it. The provider is a
|
|
329
|
+
// separate process holding the rule it was last given, so a container app that cannot run
|
|
330
|
+
// leaves a device exactly as offline as it was — and taking layers 2 and 3 down there would tell
|
|
331
|
+
// the app it is online while the kernel goes on dropping its traffic.
|
|
332
|
+
//
|
|
333
|
+
// Enforcement that has genuinely stopped is a different signal with a different remedy, and it
|
|
334
|
+
// has one: `checkLiveness` takes the layers down and reports `enforcement-lost`.
|
|
335
|
+
//
|
|
336
|
+
// The device is wherever it already was — **which is not necessarily online.** This used to
|
|
337
|
+
// delete unconditionally and answer `offline: false`, so a device that was already offline came
|
|
338
|
+
// back as online here and from every later `state()` call, with the rule and the condition file
|
|
339
|
+
// still saying otherwise. Reporting the request back as if it had taken is how a tester ends up
|
|
340
|
+
// filing bugs against an app that was never offline; reporting it as online when it is offline
|
|
341
|
+
// sends them to file against one that cannot reach anything.
|
|
342
|
+
if (was)
|
|
343
|
+
this.offline.add(udid);
|
|
344
|
+
else
|
|
345
|
+
this.offline.delete(udid);
|
|
346
|
+
// **And the restored set is written back**, because the run that failed was not necessarily the
|
|
347
|
+
// only writer. The host reads `this.offline` when it *runs*, not when it was queued — correct,
|
|
348
|
+
// since the set is the authority — so with two toggles in flight an earlier run can already have
|
|
349
|
+
// committed a later one's set. Restoring in memory alone then leaves this device named offline
|
|
350
|
+
// here and absent from the kernel rule: traffic alive, and this class saying it is not, which is
|
|
351
|
+
// the direction the paragraph above calls filing bugs against an app that was never offline.
|
|
352
|
+
//
|
|
353
|
+
// Best-effort by definition — the write that just failed may fail again — and that is still
|
|
354
|
+
// strictly better than not trying, because the alternative is a divergence nothing revisits
|
|
355
|
+
// until the device is rebooted.
|
|
356
|
+
await this.runFilterHost(was ? { add: [udid] } : { remove: [udid] });
|
|
357
|
+
this.filterVerdict.set(udid, 'unavailable');
|
|
358
|
+
this.updateLiveness();
|
|
359
|
+
return { offline: was, available: false, reason: 'filter-unavailable' };
|
|
360
|
+
}
|
|
361
|
+
this.filterVerdict.delete(udid);
|
|
362
|
+
if (offline) {
|
|
363
|
+
this.offlineSince.set(udid, Math.floor(Date.now() / 1000));
|
|
364
|
+
// **Seeded from whatever the file already says, and this is the episode boundary.**
|
|
365
|
+
//
|
|
366
|
+
// The provider prunes its own counts when a device leaves the rule, but only while rendering —
|
|
367
|
+
// and its `ruleWatch` only advances when a flow arrives. So a device toggled off, on, and off
|
|
368
|
+
// again with no flow and no pulse in between leaves the provider having never seen the empty
|
|
369
|
+
// rule, and it republishes the previous episode's count as if it belonged to this one.
|
|
370
|
+
//
|
|
371
|
+
// **The provider cannot fix that, because it cannot see an episode.** It knows a rule, not that
|
|
372
|
+
// a tester toggled a control. This class does. Taking the current count as the baseline makes a
|
|
373
|
+
// stale number harmless whatever the provider does: only drops *above* it are evidence for this
|
|
374
|
+
// episode, and if there were none the answer is silence, which is what zero has always meant
|
|
375
|
+
// here.
|
|
376
|
+
this.dropsReported.set(udid, this.readFilterState()?.droppedByDevice[udid] ?? 0);
|
|
377
|
+
}
|
|
378
|
+
else {
|
|
379
|
+
this.offlineSince.delete(udid);
|
|
380
|
+
this.dropsReported.delete(udid);
|
|
381
|
+
}
|
|
382
|
+
this.setCondition(udid, offline);
|
|
383
|
+
// **Layer 3 only reports, so its failure is not a reason and must not undo the two that do.**
|
|
384
|
+
// Unswallowed, one `status_bar` failure threw out of here with layers 1 and 2 already applied: the
|
|
385
|
+
// device really was offline and the caller was told the request failed, which is the direction
|
|
386
|
+
// that sends a tester to file against an app that was never online.
|
|
387
|
+
//
|
|
388
|
+
// Swallowed in both directions, and they fail differently. Going offline it errs *quietly* — the
|
|
389
|
+
// device is offline and the status bar has not caught up. Coming back it errs the other way: every
|
|
390
|
+
// layer is restored and the bar still shows no service, on a device whose requests now succeed.
|
|
391
|
+
// Neither is worth failing the call for. What puts the second one right is a later toggle writing
|
|
392
|
+
// the bar again — **a full cycle, not one press**, since the first press writes the value the bar
|
|
393
|
+
// is already stuck on. And it is not guaranteed: if layer 1 becomes unavailable in between, every
|
|
394
|
+
// later call returns above this line and the bar stays wrong until the device is retired. That
|
|
395
|
+
// gap, and the fact that swallowing also removes the `network:error` this used to raise, are
|
|
396
|
+
// recorded rather than solved here.
|
|
397
|
+
await this.simctl.setStatusBarOffline(udid, offline).catch((e) => {
|
|
398
|
+
console.warn(`[network] status bar for ${udid} could not be set: ${e.message}`);
|
|
399
|
+
});
|
|
400
|
+
this.updateLiveness();
|
|
401
|
+
return this.state(udid);
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* Write the rule and **ask the provider whether it is holding it.**
|
|
405
|
+
*
|
|
406
|
+
* The write's exit code cannot answer that. The container app exits when the framework accepts the
|
|
407
|
+
* save — 27ms for the whole run — and the running provider is handed the configuration afterwards
|
|
408
|
+
* with nothing coming back. Measured propagation is under 55ms and a probe measured the ask itself
|
|
409
|
+
* at 0.26–0.74ms, which is what makes asking cheaper than the six-second wait an earlier draft
|
|
410
|
+
* proposed in place of it.
|
|
411
|
+
*
|
|
412
|
+
* **The predicate is per-device membership, not set equality.** The filter is host-wide and this
|
|
413
|
+
* agent is not guaranteed to be its only writer; comparing whole sets would report every device as
|
|
414
|
+
* unenforced the moment somebody else's device appeared in the rule.
|
|
415
|
+
*
|
|
416
|
+
* **`wanted` is the value the caller asked for, and it is a parameter for that reason.** Reading it
|
|
417
|
+
* back off `this.offline` here compares the provider's answer against whatever the set says *now*,
|
|
418
|
+
* which is not necessarily what this call wrote — so a second toggle landing in between made a
|
|
419
|
+
* confirmation agree with a rule its own request had not asked for, and the success path then
|
|
420
|
+
* applied layers 2 and 3 for the wrong direction.
|
|
421
|
+
*
|
|
422
|
+
* A mismatch is logged **with what was actually read**, and with the channel it was read from.
|
|
423
|
+
* Two agents writing the same rule cannot be told apart afterwards from a log that only records
|
|
424
|
+
* what each one expected — each is internally consistent and one of them is stale.
|
|
425
|
+
*/
|
|
426
|
+
async applyAndConfirm(udid, wanted) {
|
|
427
|
+
// **Read before the write, and read the file's own clock.** The fallback below needs to tell a
|
|
428
|
+
// publication that answers this write from one that predates it, and the file stamps whole
|
|
429
|
+
// seconds — so a baseline taken from `Date.now()` here would land in the same second as the
|
|
430
|
+
// provider's reply about nine times in ten and reject the very answer it was waiting for.
|
|
431
|
+
const before = this.readFilterState()?.at ?? 0;
|
|
432
|
+
if (!await this.runFilterHost(wanted ? { add: [udid] } : { remove: [udid] }))
|
|
433
|
+
return false;
|
|
434
|
+
const seen = await this.confirmEnforcement(udid, wanted, before);
|
|
435
|
+
if (!seen)
|
|
436
|
+
return false;
|
|
437
|
+
if (!seen.enforcing)
|
|
438
|
+
return false;
|
|
439
|
+
if (seen.rule.includes(udid) !== wanted) {
|
|
440
|
+
console.warn(`[network] filter rule disagrees for ${udid}: wanted ${wanted ? 'offline' : 'online'}, ` +
|
|
441
|
+
`provider ${seen.pid} holds [${seen.rule.join(',')}] (read over ${seen.from})`);
|
|
442
|
+
return false;
|
|
443
|
+
}
|
|
444
|
+
return true;
|
|
445
|
+
}
|
|
446
|
+
/**
|
|
447
|
+
* What the filter is enforcing — **asked first, and read from the file when the asking is gone.**
|
|
448
|
+
*
|
|
449
|
+
* `--confirm` is the fast and precise channel: 0.26–0.74ms, and it answers from the process the
|
|
450
|
+
* kernel is actually consulting. It stays the primary for exactly that reason, so the healthy Mac
|
|
451
|
+
* this class runs on most of the time is untouched by any of this.
|
|
452
|
+
*
|
|
453
|
+
* **But it disappears after every system-extension replace, and stays gone.** The retired extension
|
|
454
|
+
* sits `[terminated waiting to uninstall on reboot]` still owning the mach name, so the new
|
|
455
|
+
* provider's `NSXPCListener.resume()` fails with `Operation not permitted` — silently, because
|
|
456
|
+
* `resume()` returns void. `--confirm` then answers `no listener` in 9ms while the filter is
|
|
457
|
+
* genuinely enforcing and publishing a fresh state file. Measured 2026-09-03; the provider survives
|
|
458
|
+
* `--off`/`--install` on the same pid, so the listener is never re-vended and the loss lasts as long
|
|
459
|
+
* as the provider does.
|
|
460
|
+
*
|
|
461
|
+
* Taking that as "not confirmed" is what put `filter-unavailable` in front of a tester whose filter
|
|
462
|
+
* was working, on every Mac that had upgraded. The CLI reached the same conclusion earlier and for a
|
|
463
|
+
* different reason (`net-filter.ts`: a stale binary turns a flag it does not know into a rule
|
|
464
|
+
* write); this is the agent following it.
|
|
465
|
+
*
|
|
466
|
+
* `undefined` for every way of not finding out — the remedy is the same whether the filter is
|
|
467
|
+
* absent, disabled, or restarting.
|
|
468
|
+
*/
|
|
469
|
+
async confirmEnforcement(udid, wanted, since) {
|
|
470
|
+
const asked = await this.askProvider();
|
|
471
|
+
if (asked)
|
|
472
|
+
return asked;
|
|
473
|
+
return await this.readConfirmation(udid, wanted, since);
|
|
474
|
+
}
|
|
475
|
+
/** The XPC channel. Its own failures are all the same answer, so none of them are distinguished. */
|
|
476
|
+
async askProvider() {
|
|
477
|
+
if (!existsSync(this.hostBinary))
|
|
478
|
+
return undefined;
|
|
479
|
+
try {
|
|
480
|
+
const { stdout } = await execFileAsync(this.hostBinary, ['--confirm'], {
|
|
481
|
+
timeout: FILTER_CONFIRM_TIMEOUT_MS,
|
|
482
|
+
});
|
|
483
|
+
const parsed = JSON.parse(stdout);
|
|
484
|
+
if (typeof parsed.enforcing !== 'boolean' || !Array.isArray(parsed.rule))
|
|
485
|
+
return undefined;
|
|
486
|
+
return {
|
|
487
|
+
enforcing: parsed.enforcing,
|
|
488
|
+
rule: parsed.rule.filter((r) => typeof r === 'string'),
|
|
489
|
+
pid: typeof parsed.pid === 'number' ? parsed.pid : -1,
|
|
490
|
+
from: 'xpc',
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
catch {
|
|
494
|
+
// A timeout lands here as well as a refusal, and they are the same answer: not confirmed.
|
|
495
|
+
return undefined;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
/**
|
|
499
|
+
* The file channel, polled until it answers or the deadline passes.
|
|
500
|
+
*
|
|
501
|
+
* **Three outcomes, and the middle one is the reason this is a loop rather than a read.** This runs
|
|
502
|
+
* immediately after a rule write, so the ordinary state for up to a pulse is a file that is fresh,
|
|
503
|
+
* correct, and about the *previous* rule — `checkLivenessLocked` says the same thing about the same
|
|
504
|
+
* window, and reading it as a disagreement would fire on every toggle.
|
|
505
|
+
*
|
|
506
|
+
* - The rule matches what was asked for: answered, whenever it was published.
|
|
507
|
+
* - It does not match and was published *after* the baseline: the provider has spoken and it
|
|
508
|
+
* disagrees. That is a real mismatch and is returned so the caller can log it.
|
|
509
|
+
* - It does not match and was published at or before the baseline: it has not published since the
|
|
510
|
+
* write. Wait.
|
|
511
|
+
*
|
|
512
|
+
* A stale file is never any of the three: a provider that died left it there, so it is skipped
|
|
513
|
+
* rather than believed, and `Heartbeat.remove()` means absence is what a *stopped* filter leaves.
|
|
514
|
+
*/
|
|
515
|
+
async readConfirmation(udid, wanted, since) {
|
|
516
|
+
const until = Date.now() + this.confirmDeadlineMs;
|
|
517
|
+
for (;;) {
|
|
518
|
+
const file = this.readFilterState();
|
|
519
|
+
const now = Math.floor(Date.now() / 1000);
|
|
520
|
+
// `file.at <= now` first, and it is not redundant: a timestamp from the future makes the
|
|
521
|
+
// subtraction negative and passes any threshold, so a clock that moved backwards would let a
|
|
522
|
+
// dead provider's frozen file read as perfect for as long as the skew lasted.
|
|
523
|
+
// `checkLivenessLocked` refuses the same reading for the same reason; this is its twin and
|
|
524
|
+
// had drifted from it.
|
|
525
|
+
if (file && file.at <= now && now - file.at <= 3 * Math.max(file.pulseSeconds, 1)) {
|
|
526
|
+
const answered = file.rule.includes(udid) === wanted || file.at > since;
|
|
527
|
+
if (answered)
|
|
528
|
+
return { enforcing: true, rule: file.rule, pid: file.pid ?? -1, from: 'file' };
|
|
529
|
+
}
|
|
530
|
+
if (Date.now() >= until)
|
|
531
|
+
return undefined;
|
|
532
|
+
await new Promise((resolve) => setTimeout(resolve, FILTER_FILE_POLL_MS));
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
/** Layers 2 and 3, taken down together. Layer 3 is swallowed for the reason `forget` gives: it only
|
|
536
|
+
* reports, and failing it would abandon the cleanup that matters. */
|
|
537
|
+
async takeDownLayers(udid) {
|
|
538
|
+
this.setCondition(udid, false);
|
|
539
|
+
await this.simctl.setStatusBarOffline(udid, false).catch(() => { });
|
|
540
|
+
}
|
|
541
|
+
/**
|
|
542
|
+
* What the device is doing and whether tapflow can still steer it.
|
|
543
|
+
*
|
|
544
|
+
* `offline` describes the **device**, not the last request: a simulator taken offline and then left
|
|
545
|
+
* unsteerable is still offline, and saying otherwise would draw "online" over an app that can reach
|
|
546
|
+
* nothing.
|
|
547
|
+
*/
|
|
548
|
+
state(udid) {
|
|
549
|
+
const offline = this.offline.has(udid);
|
|
550
|
+
// **Layer 1 is answered first, and from memory.** This method is synchronous — `device:ready`, a
|
|
551
|
+
// viewer's re-join and the capability's `networkState()` all arrive here — so it cannot ask
|
|
552
|
+
// anything. Deriving layer 1's health from the dylib's verdict instead would repaint a Mac that
|
|
553
|
+
// cannot take devices offline as a healthy one on the next re-join, and the tester's toast would
|
|
554
|
+
// be the only evidence it ever said otherwise.
|
|
555
|
+
const filter = this.filterVerdict.get(udid);
|
|
556
|
+
if (filter === 'lost')
|
|
557
|
+
return { offline, available: false, reason: 'enforcement-lost' };
|
|
558
|
+
if (filter === 'unavailable')
|
|
559
|
+
return { offline, available: false, reason: 'filter-unavailable' };
|
|
560
|
+
// **Layer 2 cannot work at all if the library is not on disk**, and that is the quietest failure
|
|
561
|
+
// in the feature: `DYLD_INSERT_LIBRARIES` naming a path that does not exist is ignored by dyld
|
|
562
|
+
// without a word, so the app launches unhooked and no verdict is ever written. `arm()` records
|
|
563
|
+
// the device as armed whatever the file's state — it sets the environment and does not read the
|
|
564
|
+
// path it sets — so the branch below fell to `awaiting-app`: "launch an app through tapflow", to
|
|
565
|
+
// a tester who has already launched one, for the life of the session. (`not-armed` and its
|
|
566
|
+
// reboot only appear when this process restarted, since `armed` is its memory.)
|
|
567
|
+
//
|
|
568
|
+
// Read rather than remembered, unlike layer 1 above. Layer 1 is a question this synchronous
|
|
569
|
+
// method cannot put to the provider; this one is a `stat`, and a `node_modules` emptied under a
|
|
570
|
+
// running agent is an ordinary thing to happen.
|
|
571
|
+
if (!isFile(this.dylib))
|
|
572
|
+
return { offline, available: false, reason: 'hooks-not-installed' };
|
|
573
|
+
const verdict = this.readVerdict(udid);
|
|
574
|
+
// The three answers differ by what the tester has to do, which is what the reason set is for.
|
|
575
|
+
// An absent verdict means two different things and they were reported as one: nothing was
|
|
576
|
+
// delivered (reboot), or it was delivered and no app has exercised it yet (launch one). The
|
|
577
|
+
// second is what every iOS session looks like before its app starts, so folding it into
|
|
578
|
+
// `not-armed` put the wrong remedy on the common case.
|
|
579
|
+
if (verdict === 'missing') {
|
|
580
|
+
if (!this.armed.has(udid))
|
|
581
|
+
return { offline, available: false, reason: 'not-armed' };
|
|
582
|
+
// **A launch that produced no verdict is not a device waiting for one** (#629), and this is the
|
|
583
|
+
// second form of the failure the `isFile` guard above exists for. That one covers a path dyld
|
|
584
|
+
// ignores because nothing is there; this covers a library that is there and still does not load
|
|
585
|
+
// — a wrong architecture, a runtime change, a signature. dyld says nothing in either case.
|
|
586
|
+
//
|
|
587
|
+
// Without it, `awaiting-app` says "launch an app through tapflow" to a tester who has already
|
|
588
|
+
// launched one, for the life of the session: word for word the sentence the comment above calls
|
|
589
|
+
// the quietest failure in the feature.
|
|
590
|
+
//
|
|
591
|
+
// The window before the deadline is genuinely `awaiting-app` — the dylib may still be inside its
|
|
592
|
+
// own three-second self-check — which is why this is a deadline and not a flag.
|
|
593
|
+
const launched = this.launchedAt.get(udid);
|
|
594
|
+
if (launched !== undefined && Date.now() - launched > LAUNCH_VERDICT_DEADLINE_MS) {
|
|
595
|
+
return { offline, available: false, reason: 'hooks-not-installed' };
|
|
596
|
+
}
|
|
597
|
+
return { offline, available: false, reason: 'awaiting-app' };
|
|
598
|
+
}
|
|
599
|
+
if (verdict === 'failed')
|
|
600
|
+
return { offline, available: false, reason: 'hooks-not-installed' };
|
|
601
|
+
// **`state-unconfirmed`, because that is what happened: a read that could not be confirmed.**
|
|
602
|
+
//
|
|
603
|
+
// Two members it is deliberately not. Not `awaiting-app`: resolving this against `armed` would
|
|
604
|
+
// land there almost every time — the library writes the file *because* an app is running, so
|
|
605
|
+
// `armed` is true — and `awaiting-app` is the one member drawn with a healthy control and "launch
|
|
606
|
+
// an app", which hands a normal-looking toggle to a device nobody can vouch for. And not
|
|
607
|
+
// `not-armed`, whose sentence is "restart the device". Rebooting a simulator mid-session destroys
|
|
608
|
+
// the app state under test, and none of the causes left here is fixed by it — a verdict from an
|
|
609
|
+
// older dylib is replaced by the app's next launch, and a shape this reader cannot parse is not a
|
|
610
|
+
// property of the boot. `state-unconfirmed` says "could not confirm — try again", and looking again is
|
|
611
|
+
// exactly the remedy.
|
|
612
|
+
if (verdict === 'unreadable')
|
|
613
|
+
return { offline, available: false, reason: 'state-unconfirmed' };
|
|
614
|
+
return { offline, available: true };
|
|
615
|
+
}
|
|
616
|
+
/** Called when a device goes away, so a shutdown simulator does not keep a rule alive that names
|
|
617
|
+
* it — the filter would carry the udid for the rest of the host's uptime. */
|
|
618
|
+
async forget(udid) {
|
|
619
|
+
return this.serialize(() => this.forgetLocked(udid));
|
|
620
|
+
}
|
|
621
|
+
async forgetLocked(udid) {
|
|
622
|
+
this.armed.delete(udid);
|
|
623
|
+
// The judgment was about a device that is going away. Keeping it would answer for whatever is
|
|
624
|
+
// booted into the same udid next.
|
|
625
|
+
this.filterVerdict.delete(udid);
|
|
626
|
+
this.offlineSince.delete(udid);
|
|
627
|
+
this.dropsReported.delete(udid);
|
|
628
|
+
this.launchedAt.delete(udid);
|
|
629
|
+
this.launchedPid.delete(udid);
|
|
630
|
+
// Unconditional, for the reason `arm()` gives at length: the set is this process's memory and the
|
|
631
|
+
// rule is the host's. An agent that restarted knows of no offline device, so `delete` answers
|
|
632
|
+
// false and the write was skipped — leaving the udid named in the rule for the rest of the Mac's
|
|
633
|
+
// uptime, which is the exact outcome this method's doc block says it exists to prevent.
|
|
634
|
+
this.offline.delete(udid);
|
|
635
|
+
await this.runFilterHost({ remove: [udid] });
|
|
636
|
+
this.setCondition(udid, false);
|
|
637
|
+
// **The status bar is part of what has to come back.** It was set by `setOffline` and had no
|
|
638
|
+
// other caller, so a device retired while offline kept showing no service for as long as it
|
|
639
|
+
// stayed booted — a relay disconnect was enough. That is the pixels-only false result this class
|
|
640
|
+
// exists to prevent, pointed the other way.
|
|
641
|
+
//
|
|
642
|
+
// Swallowed, and only here: a device being retired is often already gone, and `status_bar clear`
|
|
643
|
+
// against a shut-down simulator fails. Failing this call would abandon the rest of the cleanup
|
|
644
|
+
// for a layer that only reports.
|
|
645
|
+
await this.simctl.setStatusBarOffline(udid, false).catch(() => { });
|
|
646
|
+
this.updateLiveness();
|
|
647
|
+
}
|
|
648
|
+
/**
|
|
649
|
+
* Point the report somewhere after construction.
|
|
650
|
+
*
|
|
651
|
+
* **The constructor option alone left this untestable, which a review found by mutating the wiring
|
|
652
|
+
* away and watching the whole suite stay green.** `IOSAgent` builds its own `SimulatorNetwork` only
|
|
653
|
+
* when one was not injected, and every test injects one — so the handler was attached on exactly the
|
|
654
|
+
* path no test takes. The one channel that tells a tester their finished check was invalidated had
|
|
655
|
+
* no coverage at all, while the refusal path beside it had five tests.
|
|
656
|
+
*/
|
|
657
|
+
setEnforcementLostHandler(fn) {
|
|
658
|
+
this.enforcementLost = fn;
|
|
659
|
+
}
|
|
660
|
+
/**
|
|
661
|
+
* Stop watching. **Nothing else clears the interval**, so an agent that shuts down while a device
|
|
662
|
+
* is offline would otherwise keep a timer alive for the life of the process — which is what
|
|
663
|
+
* happened, because for a while this had no caller outside the tests at all. `IOSAgent.disconnect`
|
|
664
|
+
* owns it, beside the resources timer and the tree reader it already stops.
|
|
665
|
+
*/
|
|
666
|
+
dispose() {
|
|
667
|
+
this.disposed = true;
|
|
668
|
+
if (this.liveness)
|
|
669
|
+
clearInterval(this.liveness);
|
|
670
|
+
this.liveness = undefined;
|
|
671
|
+
}
|
|
672
|
+
/**
|
|
673
|
+
* Undo `dispose`, because an agent that disconnected can connect again on the same instance.
|
|
674
|
+
*
|
|
675
|
+
* **A one-way disposed flag is a bug with the shape of the one it fixes**, and this codebase has
|
|
676
|
+
* already shipped it once: the provider's `Heartbeat.stopped` was set by a stop and cleared by
|
|
677
|
+
* nothing, so the first `stopFilter` in a process killed the state file for good while the filter
|
|
678
|
+
* went on filtering. Here it would be quieter and worse — `connect()` is public and reuses the
|
|
679
|
+
* network, so a reconnect after a disconnect would leave the watcher off, and the one report that
|
|
680
|
+
* tells a tester their check was invalidated would simply never come.
|
|
681
|
+
*/
|
|
682
|
+
resume() {
|
|
683
|
+
this.disposed = false;
|
|
684
|
+
this.updateLiveness();
|
|
685
|
+
}
|
|
686
|
+
// ── liveness: enforcement that stops after the fact ────────────────────────
|
|
687
|
+
/**
|
|
688
|
+
* Watch only while something is offline, because that is the only time there is anything to lose.
|
|
689
|
+
*
|
|
690
|
+
* The interval is `unref`'d: this must not be the reason a process stays alive.
|
|
691
|
+
*/
|
|
692
|
+
updateLiveness() {
|
|
693
|
+
const wanted = !this.disposed && this.offline.size > 0;
|
|
694
|
+
if (wanted && !this.liveness) {
|
|
695
|
+
this.liveness = setInterval(() => { void this.checkLiveness(); }, this.livenessIntervalMs);
|
|
696
|
+
this.liveness.unref?.();
|
|
697
|
+
}
|
|
698
|
+
else if (!wanted && this.liveness) {
|
|
699
|
+
clearInterval(this.liveness);
|
|
700
|
+
this.liveness = undefined;
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
/**
|
|
704
|
+
* Notice that a device stopped being enforced, and say so.
|
|
705
|
+
*
|
|
706
|
+
* **Why this exists at all, given the confirmation on the write.** The confirmation answers the
|
|
707
|
+
* moment of the request; enforcement can stop at any point afterwards, and when it does the tester
|
|
708
|
+
* is looking at a control that still says offline. Measured on the reference Mac: killing the
|
|
709
|
+
* provider leaves the kernel passing that simulator's traffic for about 5.8 seconds before launchd
|
|
710
|
+
* has it back, and 23 to 27 requests got through each time. The tester's sign-off covers requests
|
|
711
|
+
* that succeeded.
|
|
712
|
+
*
|
|
713
|
+
* **The threshold comes out of the file.** Three pulses, at whatever rate the provider says it is
|
|
714
|
+
* pulsing — 1s while it is enforcing, so about three seconds. Hard-coding fifteen (three of the old
|
|
715
|
+
* five-second pulses) is what made the outage above arithmetically invisible: the gap closes before
|
|
716
|
+
* the threshold expires and nothing is ever reported.
|
|
717
|
+
*
|
|
718
|
+
* **A timestamp in the future is not freshness, it is a file that cannot be trusted.** Clocks move
|
|
719
|
+
* backwards — NTP corrections, a sleeping Mac — and treating `at > now` as "very fresh" would make
|
|
720
|
+
* a stale file look perfect for as long as the skew lasted.
|
|
721
|
+
*/
|
|
722
|
+
async checkLiveness() {
|
|
723
|
+
// Queued like every other mutation. It edits `this.offline`, rewrites the rule and takes layers
|
|
724
|
+
// down, so running it beside a toggle is the race described on `serialize` — and the tick is the
|
|
725
|
+
// half that used to run outside the boundary.
|
|
726
|
+
if (this.livenessQueued)
|
|
727
|
+
return;
|
|
728
|
+
this.livenessQueued = true;
|
|
729
|
+
try {
|
|
730
|
+
await this.serialize(() => this.checkLivenessLocked());
|
|
731
|
+
}
|
|
732
|
+
finally {
|
|
733
|
+
this.livenessQueued = false;
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
/**
|
|
737
|
+
* **Three questions per device, and each one is a different piece of evidence.** A single "is the
|
|
738
|
+
* file stale" test read against the whole set could not answer them, and the gap it left is
|
|
739
|
+
* measured: the provider publishes its pulse rate *as of the rule it held when it wrote*, so the
|
|
740
|
+
* last write before a device went offline says `pulseSeconds: 5`. A provider dying in the second
|
|
741
|
+
* after a toggle therefore leaves a file that is not stale by its own declared rate for fifteen
|
|
742
|
+
* seconds, and does not name the device either — both predicates false, nothing reported, and the
|
|
743
|
+
* kernel passing that simulator's traffic for the whole of it.
|
|
744
|
+
*/
|
|
745
|
+
async checkLivenessLocked() {
|
|
746
|
+
if (this.offline.size === 0)
|
|
747
|
+
return;
|
|
748
|
+
const file = this.readFilterState();
|
|
749
|
+
const now = Math.floor(Date.now() / 1000);
|
|
750
|
+
const lost = [...this.offline].filter((udid) => {
|
|
751
|
+
// No file at all, or a timestamp from the future — a clock that moved backwards must not read
|
|
752
|
+
// as very fresh, or a frozen file looks perfect for as long as the skew lasts.
|
|
753
|
+
if (!file || file.at > now)
|
|
754
|
+
return true;
|
|
755
|
+
const since = this.offlineSince.get(udid) ?? now;
|
|
756
|
+
// Named in the rule: only the file going stale can lose it, at whatever rate the file declares.
|
|
757
|
+
// Per device rather than by set equality — the filter is host-wide, and somebody else's device
|
|
758
|
+
// appearing in the rule must not make this one look unenforced.
|
|
759
|
+
if (file.rule.includes(udid))
|
|
760
|
+
return now - file.at > 3 * Math.max(file.pulseSeconds, 1);
|
|
761
|
+
// Not named, and the file was written before this device's rule was confirmed: the provider has
|
|
762
|
+
// simply not published since. That is the ordinary state for about a second after every toggle,
|
|
763
|
+
// and reading it as a disagreement fires on every one of them. It stops being ordinary once the
|
|
764
|
+
// provider has had three of its enforcing pulses to say something.
|
|
765
|
+
if (file.at <= since)
|
|
766
|
+
return now - since > 3 * ENFORCING_PULSE_SECONDS;
|
|
767
|
+
// Not named, and published *after* the confirmation. The provider has spoken and this device is
|
|
768
|
+
// not in what it said.
|
|
769
|
+
return true;
|
|
770
|
+
});
|
|
771
|
+
// **Q1, resolved: the evidence goes in the log and not on the wire.** Adding a drop count to
|
|
772
|
+
// `NetworkStatePayload` would be a protocol change for a number a tester cannot act on, and the
|
|
773
|
+
// one-directional reading below is not something a control can render honestly — "0 drops" looks
|
|
774
|
+
// like a failure and is not. A person diagnosing reads this line; nothing else changes.
|
|
775
|
+
//
|
|
776
|
+
// Deliberately outside the `lost` branch: it is worth saying that a device is *proven* enforcing,
|
|
777
|
+
// not only that one stopped being.
|
|
778
|
+
if (file) {
|
|
779
|
+
// **Over `this.offline`, never over the file's own keys.** The state file is host-wide: another
|
|
780
|
+
// agent's devices and rule entries this instance never wrote can be in it, and claiming evidence
|
|
781
|
+
// about those is the same mistake the loop above refuses to make — "somebody else's device
|
|
782
|
+
// appearing in the rule must not make this one look unenforced", pointed the other way.
|
|
783
|
+
for (const udid of this.offline) {
|
|
784
|
+
// **Not for a device this tick just declared lost.** The file that froze is where both
|
|
785
|
+
// readings come from, so without this the log says "enforcement observed" and then reports
|
|
786
|
+
// that enforcement stopped, one line apart, about the same device — a flat contradiction
|
|
787
|
+
// handed to the person the line exists for.
|
|
788
|
+
if (lost.includes(udid))
|
|
789
|
+
continue;
|
|
790
|
+
const drops = file.droppedByDevice[udid] ?? 0;
|
|
791
|
+
// **Only an increase is news.** This runs once a second, so a device with any drops at all
|
|
792
|
+
// would repeat the same line for as long as it stays offline — 600 of them in a ten-minute
|
|
793
|
+
// session, in the stream where the enforcement-lost report actually matters. A count that
|
|
794
|
+
// went *down* is a provider that restarted; it re-reports from there rather than staying
|
|
795
|
+
// silent until it passes the old high-water mark.
|
|
796
|
+
const reported = this.dropsReported.get(udid) ?? 0;
|
|
797
|
+
if (drops > reported) {
|
|
798
|
+
console.log(`[network] filter has dropped ${drops} flow(s) for ${udid} — enforcement observed, not just delivered`);
|
|
799
|
+
}
|
|
800
|
+
if (drops !== reported)
|
|
801
|
+
this.dropsReported.set(udid, drops);
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
if (lost.length === 0)
|
|
805
|
+
return;
|
|
806
|
+
for (const udid of lost) {
|
|
807
|
+
this.offline.delete(udid);
|
|
808
|
+
this.offlineSince.delete(udid);
|
|
809
|
+
this.dropsReported.delete(udid);
|
|
810
|
+
this.filterVerdict.set(udid, 'lost');
|
|
811
|
+
}
|
|
812
|
+
// **`remove: lost` is the whole point of this write, and naming only `add` was a regression.**
|
|
813
|
+
//
|
|
814
|
+
// The divergence being repaired is caused by the devices just deleted from `this.offline`. The
|
|
815
|
+
// whole-set write this replaced removed them by writing a set they were no longer in; a delta has
|
|
816
|
+
// to say so. Without it they stay in the host's rule, launchd brings the provider back in about
|
|
817
|
+
// six seconds, it re-reads the persisted configuration, and the kernel drops that simulator's
|
|
818
|
+
// traffic again — while layers 2 and 3 are down, `state()` answers `offline: false`, and the
|
|
819
|
+
// watcher has stopped because the set is empty. Traffic dead and reported online, which is the
|
|
820
|
+
// one direction this class exists to prevent.
|
|
821
|
+
//
|
|
822
|
+
// `add` stays, and not as symmetry: a device can be in this set with the rule not naming it, when
|
|
823
|
+
// `setOffline`'s failure path restored it in memory and its best-effort rewrite failed too.
|
|
824
|
+
await this.runFilterHost({ add: [...this.offline], remove: lost });
|
|
825
|
+
for (const udid of lost) {
|
|
826
|
+
// **Telling the tester is the remedy; taking the layers down is the tidying up.** The device is
|
|
827
|
+
// already reachable — that is what was detected — so leaving the app believing otherwise would
|
|
828
|
+
// add a second false state on top of the one being reported.
|
|
829
|
+
await this.takeDownLayers(udid);
|
|
830
|
+
this.enforcementLost(udid);
|
|
831
|
+
}
|
|
832
|
+
this.updateLiveness();
|
|
833
|
+
}
|
|
834
|
+
/** The first candidate that parses wins. A file that is present but unreadable is not evidence of
|
|
835
|
+
* anything, so it is treated as absent rather than as a reason to stop looking. */
|
|
836
|
+
readFilterState() {
|
|
837
|
+
for (const path of this.stateFiles) {
|
|
838
|
+
if (!existsSync(path))
|
|
839
|
+
continue;
|
|
840
|
+
try {
|
|
841
|
+
const raw = JSON.parse(readFileSync(path, 'utf8'));
|
|
842
|
+
if (typeof raw.at !== 'number' || !Array.isArray(raw.rule))
|
|
843
|
+
continue;
|
|
844
|
+
return {
|
|
845
|
+
at: raw.at,
|
|
846
|
+
pulseSeconds: typeof raw.pulseSeconds === 'number' ? raw.pulseSeconds : 5,
|
|
847
|
+
rule: raw.rule.filter((r) => typeof r === 'string'),
|
|
848
|
+
// **Its absence is not a parse failure**, and that is the load-bearing part. The provider is
|
|
849
|
+
// installed by hand and replaced only on reboot, so a Mac running a provider older than
|
|
850
|
+
// #654 is the ordinary case during rollout — treating a missing field as a bad file would
|
|
851
|
+
// report "not enforcing" for a filter doing its job, which is the exact failure the rest of
|
|
852
|
+
// this file exists to prevent. A malformed one is discarded the same way, entry by entry.
|
|
853
|
+
droppedByDevice: numbersIn(raw.droppedByDevice),
|
|
854
|
+
// Same rule as the field above it: a provider that does not publish this is not a bad file.
|
|
855
|
+
pid: typeof raw.pid === 'number' ? raw.pid : undefined,
|
|
856
|
+
};
|
|
857
|
+
}
|
|
858
|
+
catch {
|
|
859
|
+
continue;
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
return undefined;
|
|
863
|
+
}
|
|
864
|
+
// ── layer 1 ────────────────────────────────────────────────────────────────
|
|
865
|
+
/**
|
|
866
|
+
* **Serialised, and bounded in time.**
|
|
867
|
+
*
|
|
868
|
+
* The host takes the whole offline set on each run and the last writer wins, so two of these in
|
|
869
|
+
* flight at once decide the rule by which subprocess happens to finish last rather than by which
|
|
870
|
+
* request came last. Two devices toggled in the same second is enough — and the set each one reads
|
|
871
|
+
* is correct, which is what makes the wrong outcome hard to see afterwards: both runs are internally
|
|
872
|
+
* consistent and one of them is stale.
|
|
873
|
+
*
|
|
874
|
+
* **What is serialised is the whole operation, and an earlier version serialised only the host
|
|
875
|
+
* run.** That version released the queue the moment the rule was written, so the confirmation that
|
|
876
|
+
* follows ran outside it — and a review found two ways that breaks. A liveness tick landing between
|
|
877
|
+
* a device joining the offline set and its confirmation declared that device's enforcement lost,
|
|
878
|
+
* rewrote the rule without it, took layers 2 and 3 down, and told every session; the confirmation
|
|
879
|
+
* then came back, compared against a set the tick had already edited, agreed with itself, and put
|
|
880
|
+
* layers 2 and 3 **back on** over a kernel rule that no longer named the device. Two toggles of the
|
|
881
|
+
* same device overlapping produced the mirror image: a fully healthy-looking offline control with
|
|
882
|
+
* layer 2 taken down under it. Both end in the state this class exists to prevent — the app told it
|
|
883
|
+
* is offline while its requests succeed — so the boundary has to contain the confirmation, and
|
|
884
|
+
* every reader and writer of `offline` has to be inside it.
|
|
885
|
+
*
|
|
886
|
+
* The timeout covers a host that never returns. It waits on `OSSystemExtensionRequest`, and one of
|
|
887
|
+
* its outcomes is a System Settings dialog nobody is standing in front of; the binary now exits
|
|
888
|
+
* itself on that path, but a timeout here is what keeps a wedge from taking the queue with it —
|
|
889
|
+
* everything after it is waiting on this chain.
|
|
890
|
+
*/
|
|
891
|
+
serialize(work) {
|
|
892
|
+
const run = this.filterQueue.then(work);
|
|
893
|
+
// Keep the chain alive whatever this run did: a rejection left on it would fail every later call.
|
|
894
|
+
this.filterQueue = run.then(() => undefined, () => undefined);
|
|
895
|
+
return run;
|
|
896
|
+
}
|
|
897
|
+
/**
|
|
898
|
+
* The container app writes the rule and exits.
|
|
899
|
+
*
|
|
900
|
+
* **Its exit is not the confirmation.** Zero means the container app launched and the framework
|
|
901
|
+
* accepted the save — the whole run is 27ms, measured — and the running provider is handed the new
|
|
902
|
+
* configuration afterwards, with nothing coming back to say it has it. So this answers "nothing
|
|
903
|
+
* refused"; whether the device is actually offline is answered by `state()`, from evidence the
|
|
904
|
+
* dylib wrote inside the simulator.
|
|
905
|
+
*/
|
|
906
|
+
/**
|
|
907
|
+
* **Names the devices it is changing, and nothing else.**
|
|
908
|
+
*
|
|
909
|
+
* This used to write `[...this.offline]` — the whole set — and the host replaced the rule with it.
|
|
910
|
+
* The rule is host-wide, so that made every run an assertion about devices this agent had never
|
|
911
|
+
* heard of: a second agent starting knows of no offline device, and `arm()` runs on every device
|
|
912
|
+
* boot, so **starting one put every device the other had taken offline back online**, silently,
|
|
913
|
+
* while its tester watched an offline control over working traffic.
|
|
914
|
+
*
|
|
915
|
+
* A delta cannot say anything about a device it does not name. The cleanup the whole-set write used
|
|
916
|
+
* to provide is not lost, only made precise: `armLocked` names the udid that just booted in
|
|
917
|
+
* `remove`, and a rule left behind by a dead process is cleared the next time that device comes up
|
|
918
|
+
* rather than by wiping the host's.
|
|
919
|
+
*/
|
|
920
|
+
async runFilterHost(delta) {
|
|
921
|
+
if (!existsSync(this.hostBinary))
|
|
922
|
+
return false;
|
|
923
|
+
const args = [];
|
|
924
|
+
if (delta.add?.length)
|
|
925
|
+
args.push('--add', delta.add.join(','));
|
|
926
|
+
if (delta.remove?.length)
|
|
927
|
+
args.push('--remove', delta.remove.join(','));
|
|
928
|
+
// **Nothing to say means do not run it, and that is load-bearing rather than an optimisation.**
|
|
929
|
+
// The host reads the absence of both flags as "clear the rule" — deliberately, because a person
|
|
930
|
+
// whose agent died holding a rule has no other way to empty it. So an agent that ran it with an
|
|
931
|
+
// empty delta would wipe the host's rule while believing it had done nothing.
|
|
932
|
+
if (args.length === 0)
|
|
933
|
+
return true;
|
|
934
|
+
try {
|
|
935
|
+
await execFileAsync(this.hostBinary, args, { timeout: FILTER_HOST_TIMEOUT_MS });
|
|
936
|
+
return true;
|
|
937
|
+
}
|
|
938
|
+
catch {
|
|
939
|
+
return false;
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
// ── layer 2 ────────────────────────────────────────────────────────────────
|
|
943
|
+
conditionPath(udid) {
|
|
944
|
+
return `${this.conditionDir}/tapflow-offline-${udid}`;
|
|
945
|
+
}
|
|
946
|
+
setCondition(udid, offline) {
|
|
947
|
+
const path = this.conditionPath(udid);
|
|
948
|
+
if (!offline) {
|
|
949
|
+
rmSync(path, { force: true });
|
|
950
|
+
return;
|
|
951
|
+
}
|
|
952
|
+
mkdirSync(this.conditionDir, { recursive: true });
|
|
953
|
+
writeFileSync(path, '');
|
|
954
|
+
}
|
|
955
|
+
/**
|
|
956
|
+
* `missing`, `failed` and `unreadable` are three different answers and a tester needs all of them.
|
|
957
|
+
*
|
|
958
|
+
* Missing is not by itself an answer — it is the same file being absent whether the injection was
|
|
959
|
+
* never delivered or is simply waiting for its first app, which is why `state` reads it against
|
|
960
|
+
* `armed` rather than reporting it directly. Failed means the dylib ran and proved by trying that
|
|
961
|
+
* its hooks did not take, which no amount of relaunching will change.
|
|
962
|
+
*
|
|
963
|
+
* Unreadable is neither, and folding it into `failed` was this reader claiming evidence it does not
|
|
964
|
+
* have: a file this reader cannot parse says nothing about whether the hooks took. **The library
|
|
965
|
+
* now writes it with `rename` and no longer produces torn ones (#653)**, which removes the cause
|
|
966
|
+
* that was reachable on a healthy session — not the member. A simulator still running an app that
|
|
967
|
+
* was launched by an older dylib writes the old way, and a file whose shape this reader does not
|
|
968
|
+
* recognise is unreadable however it got there. Unlike the other two it is **not** resolved against
|
|
969
|
+
* `armed` — see `state` for why that would be worse than the problem.
|
|
970
|
+
*/
|
|
971
|
+
verdictPath(udid) {
|
|
972
|
+
return `${this.verdictDir}/tapflow-nethook-${udid}.json`;
|
|
973
|
+
}
|
|
974
|
+
readVerdict(udid) {
|
|
975
|
+
const path = this.verdictPath(udid);
|
|
976
|
+
if (!existsSync(path))
|
|
977
|
+
return 'missing';
|
|
978
|
+
try {
|
|
979
|
+
const raw = JSON.parse(readFileSync(path, 'utf8'));
|
|
980
|
+
if (raw.installed === true)
|
|
981
|
+
return 'ok';
|
|
982
|
+
// **`failed` is the library's own signal and nothing else.** Testing for `!== true` swept up
|
|
983
|
+
// every shape that is not it — `{}`, `[]`, a bare number, a bare string — and answered
|
|
984
|
+
// "the library ran and proved its hooks did not take" about a file that shows no such thing.
|
|
985
|
+
// That is the same overclaim this branch was split out to remove, one case over.
|
|
986
|
+
return raw.installed === false ? 'failed' : 'unreadable';
|
|
987
|
+
}
|
|
988
|
+
catch {
|
|
989
|
+
// A file whose shape says nothing, or one written by a dylib from before the write became
|
|
990
|
+
// atomic (#653). Says nothing about the hooks either way, which is the whole reason it is not
|
|
991
|
+
// `failed`.
|
|
992
|
+
return 'unreadable';
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
//# sourceMappingURL=SimulatorNetwork.js.map
|