@ceralive/modem-control 0.1.0 → 1.0.0
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/backend/device-classifier.ts +8 -0
- package/src/backend/features.test.ts +4 -0
- package/src/backend/index.ts +15 -0
- package/src/backend/uhubctl-power-hook.test.ts +274 -0
- package/src/backend/uhubctl-power-hook.ts +377 -0
- package/src/backend/usage/index.ts +23 -0
- package/src/backend/usage/policy-store.test.ts +164 -0
- package/src/backend/usage/policy-store.ts +216 -0
- package/src/backend/usage/policy-write.test.ts +198 -0
- package/src/backend/usage/policy-write.ts +207 -0
- package/src/backend/usage/sampler.test.ts +108 -0
- package/src/backend/usage/sampler.ts +57 -3
- package/src/ports/forbidden-surface.test.ts +174 -13
- package/src/transport/calls.ts +113 -0
- package/src/transport/characterization.test.ts +260 -0
- package/src/transport/no-library-leak.test.ts +13 -2
- package/src/transport/signals.ts +150 -0
- package/src/transport/test-support/fake-service.ts +27 -2
- package/src/transport/transport.ts +24 -213
- package/src/usb-mode/index.ts +32 -1
- package/src/usb-mode/ingestion.test.ts +268 -0
- package/src/usb-mode/ingestion.ts +297 -0
- package/src/usb-mode/promotion-review.ts +117 -0
- package/src/usb-mode/usb-devices-parse.ts +196 -0
|
@@ -217,3 +217,111 @@ describe('UsageSampler — reboot (new boot id) re-baselines without losing the
|
|
|
217
217
|
expect(rebooted.snapshot().slots[0]?.cycleBytes).toBe(350);
|
|
218
218
|
});
|
|
219
219
|
});
|
|
220
|
+
|
|
221
|
+
describe('UsageSampler — applyUsagePolicy (the setUsagePolicy live-apply seam)', () => {
|
|
222
|
+
const AUG_16 = Date.UTC(2026, 7, 16, 12, 0, 0);
|
|
223
|
+
|
|
224
|
+
async function samplerAt(now: number) {
|
|
225
|
+
const counters = new FakeCounters();
|
|
226
|
+
const sampler = await createUsageSampler({
|
|
227
|
+
bootId: 'boot-1',
|
|
228
|
+
source: counters,
|
|
229
|
+
store: new MemStore(),
|
|
230
|
+
now: () => now,
|
|
231
|
+
});
|
|
232
|
+
return { counters, sampler };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
test('a threshold-only change takes effect immediately and resets nothing', async () => {
|
|
236
|
+
const { counters, sampler } = await samplerAt(AUG_16);
|
|
237
|
+
counters.set('wwan0', 100);
|
|
238
|
+
await sampler.sample([obs(SLOT_A, 'wwan0')]);
|
|
239
|
+
counters.set('wwan0', 900);
|
|
240
|
+
await sampler.sample([obs(SLOT_A, 'wwan0')]);
|
|
241
|
+
|
|
242
|
+
const applied = sampler.applyUsagePolicy(SLOT_A, { thresholdBytes: 500 });
|
|
243
|
+
|
|
244
|
+
expect(applied.cycleReset).toBe(false);
|
|
245
|
+
const slot = sampler.snapshot().slots[0];
|
|
246
|
+
expect(slot?.cycleBytes).toBe(800);
|
|
247
|
+
expect(slot?.thresholdBytes).toBe(500);
|
|
248
|
+
expect(slot?.thresholdExceeded).toBe(true);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test('a CHANGED cycle day restarts the window at zero and re-anchors it', async () => {
|
|
252
|
+
const { counters, sampler } = await samplerAt(AUG_16);
|
|
253
|
+
counters.set('wwan0', 100);
|
|
254
|
+
await sampler.sample([obs(SLOT_A, 'wwan0')]);
|
|
255
|
+
counters.set('wwan0', 900);
|
|
256
|
+
await sampler.sample([obs(SLOT_A, 'wwan0')]);
|
|
257
|
+
expect(sampler.snapshot().slots[0]?.cycleBytes).toBe(800);
|
|
258
|
+
|
|
259
|
+
const applied = sampler.applyUsagePolicy(SLOT_A, { cycleDay: 20 });
|
|
260
|
+
|
|
261
|
+
expect(applied.cycleReset).toBe(true);
|
|
262
|
+
expect(applied.cycleStartMs).toBe(Date.UTC(2026, 6, 20));
|
|
263
|
+
const slot = sampler.snapshot().slots[0];
|
|
264
|
+
expect(slot?.cycleBytes).toBe(0);
|
|
265
|
+
expect(slot?.cycleStartMs).toBe(Date.UTC(2026, 6, 20));
|
|
266
|
+
expect(slot?.cycleDay).toBe(20);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test('the BASELINE survives the reset, so the next sample attributes no jump', async () => {
|
|
270
|
+
const { counters, sampler } = await samplerAt(AUG_16);
|
|
271
|
+
counters.set('wwan0', 1_000_000);
|
|
272
|
+
await sampler.sample([obs(SLOT_A, 'wwan0')]);
|
|
273
|
+
|
|
274
|
+
sampler.applyUsagePolicy(SLOT_A, { cycleDay: 20 });
|
|
275
|
+
counters.set('wwan0', 1_000_150);
|
|
276
|
+
await sampler.sample([obs(SLOT_A, 'wwan0', { cycleDay: 20 })]);
|
|
277
|
+
|
|
278
|
+
expect(sampler.snapshot().slots[0]?.cycleBytes).toBe(150);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test('re-applying the SAME cycle day is a no-op — repeated saves never zero a window', async () => {
|
|
282
|
+
const { counters, sampler } = await samplerAt(AUG_16);
|
|
283
|
+
counters.set('wwan0', 100);
|
|
284
|
+
await sampler.sample([obs(SLOT_A, 'wwan0', { cycleDay: 20 })]);
|
|
285
|
+
counters.set('wwan0', 400);
|
|
286
|
+
await sampler.sample([obs(SLOT_A, 'wwan0', { cycleDay: 20 })]);
|
|
287
|
+
|
|
288
|
+
const applied = sampler.applyUsagePolicy(SLOT_A, { cycleDay: 20 });
|
|
289
|
+
|
|
290
|
+
expect(applied.cycleReset).toBe(false);
|
|
291
|
+
expect(sampler.snapshot().slots[0]?.cycleBytes).toBe(300);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test('an applied policy OUTRANKS a stale observation on the next sample', async () => {
|
|
295
|
+
const { counters, sampler } = await samplerAt(AUG_16);
|
|
296
|
+
counters.set('wwan0', 100);
|
|
297
|
+
await sampler.sample([obs(SLOT_A, 'wwan0', { thresholdBytes: 10 })]);
|
|
298
|
+
|
|
299
|
+
sampler.applyUsagePolicy(SLOT_A, { thresholdBytes: 999 });
|
|
300
|
+
// The composition root has not rebuilt its observations yet and still
|
|
301
|
+
// carries the OLD policy — the write must not silently revert.
|
|
302
|
+
await sampler.sample([obs(SLOT_A, 'wwan0', { thresholdBytes: 10 })]);
|
|
303
|
+
|
|
304
|
+
expect(sampler.snapshot().slots[0]?.thresholdBytes).toBe(999);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
test('applying to a slot never sampled creates it without claiming any bytes', async () => {
|
|
308
|
+
const { sampler } = await samplerAt(AUG_16);
|
|
309
|
+
|
|
310
|
+
const applied = sampler.applyUsagePolicy(SLOT_B, { cycleDay: 3, thresholdBytes: 7 });
|
|
311
|
+
|
|
312
|
+
expect(applied.cycleReset).toBe(false);
|
|
313
|
+
const slot = sampler.snapshot().slots[0];
|
|
314
|
+
expect(slot?.logicalSlotId).toBe('slot-b');
|
|
315
|
+
expect(slot?.cycleBytes).toBe(0);
|
|
316
|
+
expect(slot?.cycleDay).toBe(3);
|
|
317
|
+
expect(slot?.thresholdBytes).toBe(7);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
test('a slot with no policy reports no cycleDay rather than the sampler default', async () => {
|
|
321
|
+
const { counters, sampler } = await samplerAt(AUG_16);
|
|
322
|
+
counters.set('wwan0', 10);
|
|
323
|
+
await sampler.sample([obs(SLOT_A, 'wwan0')]);
|
|
324
|
+
|
|
325
|
+
expect(sampler.snapshot().slots[0]?.cycleDay).toBeUndefined();
|
|
326
|
+
});
|
|
327
|
+
});
|
|
@@ -36,6 +36,8 @@ export interface SlotUsageSnapshot {
|
|
|
36
36
|
readonly cycleBytes: number;
|
|
37
37
|
readonly cycleStartMs: number;
|
|
38
38
|
readonly paused: boolean;
|
|
39
|
+
/** The cycle day in force for this slot, when the operator set one. */
|
|
40
|
+
readonly cycleDay?: number;
|
|
39
41
|
readonly thresholdBytes?: number;
|
|
40
42
|
/** Advisory-only: `cycleBytes > thresholdBytes`. Never gates the connection. */
|
|
41
43
|
readonly thresholdExceeded: boolean;
|
|
@@ -85,6 +87,13 @@ export class UsageSampler {
|
|
|
85
87
|
readonly #defaultCycleDay: number;
|
|
86
88
|
readonly #accounts = new Map<string, SlotAccount>();
|
|
87
89
|
readonly #policies = new Map<string, DesiredUsage>();
|
|
90
|
+
// Policies written through `applyUsagePolicy` OUTRANK whatever an observation
|
|
91
|
+
// carries, for the life of the process. Without this, the next `sample()` would
|
|
92
|
+
// clobber a just-applied write with the policy the composition root happened to
|
|
93
|
+
// build its observation from — and the operator would watch their setting
|
|
94
|
+
// revert. The durable store is the source of truth for both, so an override and
|
|
95
|
+
// an observation can only ever disagree inside that window.
|
|
96
|
+
readonly #policyOverrides = new Map<string, DesiredUsage>();
|
|
88
97
|
#lastPersistMs: number;
|
|
89
98
|
#dirty = false;
|
|
90
99
|
|
|
@@ -145,8 +154,9 @@ export class UsageSampler {
|
|
|
145
154
|
const now = this.#now();
|
|
146
155
|
for (const obs of observations) {
|
|
147
156
|
const slotId = obs.logicalSlotId as string;
|
|
148
|
-
this.#
|
|
149
|
-
|
|
157
|
+
const usage = this.#policyOverrides.get(slotId) ?? obs.usage;
|
|
158
|
+
this.#policies.set(slotId, usage);
|
|
159
|
+
const cycleDay = usage.cycleDay ?? this.#defaultCycleDay;
|
|
150
160
|
const cycleStartMs = cycleStart(epochMillis(now), cycleDay);
|
|
151
161
|
const current = counters.get(obs.ifname);
|
|
152
162
|
if (current === undefined) {
|
|
@@ -179,12 +189,14 @@ export class UsageSampler {
|
|
|
179
189
|
const generatedAtMs = this.#now();
|
|
180
190
|
const slots: SlotUsageSnapshot[] = [];
|
|
181
191
|
for (const [slotId, account] of this.#accounts) {
|
|
182
|
-
const
|
|
192
|
+
const policy = this.#policies.get(slotId);
|
|
193
|
+
const thresholdBytes = policy?.thresholdBytes;
|
|
183
194
|
slots.push({
|
|
184
195
|
logicalSlotId: slotId,
|
|
185
196
|
cycleBytes: account.cycleBytes,
|
|
186
197
|
cycleStartMs: account.cycleStartMs,
|
|
187
198
|
paused: account.paused,
|
|
199
|
+
...(policy?.cycleDay !== undefined ? { cycleDay: policy.cycleDay } : {}),
|
|
188
200
|
...(thresholdBytes !== undefined ? { thresholdBytes } : {}),
|
|
189
201
|
thresholdExceeded: thresholdBytes !== undefined && account.cycleBytes > thresholdBytes,
|
|
190
202
|
});
|
|
@@ -192,6 +204,48 @@ export class UsageSampler {
|
|
|
192
204
|
return { bootId: this.#bootId, generatedAtMs, slots };
|
|
193
205
|
}
|
|
194
206
|
|
|
207
|
+
/**
|
|
208
|
+
* Apply an operator's usage policy to this slot immediately, without waiting
|
|
209
|
+
* for the next sampling pass.
|
|
210
|
+
*
|
|
211
|
+
* A CHANGED CYCLE ANCHOR RESTARTS THE WINDOW AT ZERO, and keeps the counter
|
|
212
|
+
* BASELINE. Those two halves are the honest answer to a question with no
|
|
213
|
+
* truthful one: bytes already accrued were measured under the OLD window, so
|
|
214
|
+
* carrying them into the new one over-reports it, and there is no record of
|
|
215
|
+
* how they were distributed within it. Starting fresh states plainly that the
|
|
216
|
+
* new window began now; keeping `lastObserved` means the next sample still
|
|
217
|
+
* attributes only genuinely new bytes, never a jump. A threshold-only change
|
|
218
|
+
* moves no anchor and therefore resets nothing.
|
|
219
|
+
*/
|
|
220
|
+
applyUsagePolicy(
|
|
221
|
+
logicalSlotId: string,
|
|
222
|
+
usage: DesiredUsage,
|
|
223
|
+
atMs?: number,
|
|
224
|
+
): {
|
|
225
|
+
cycleStartMs: number;
|
|
226
|
+
cycleReset: boolean;
|
|
227
|
+
} {
|
|
228
|
+
const now = atMs ?? this.#now();
|
|
229
|
+
this.#policyOverrides.set(logicalSlotId, usage);
|
|
230
|
+
this.#policies.set(logicalSlotId, usage);
|
|
231
|
+
const cycleStartMs = cycleStart(
|
|
232
|
+
epochMillis(now),
|
|
233
|
+
usage.cycleDay ?? this.#defaultCycleDay,
|
|
234
|
+
) as number;
|
|
235
|
+
const account = this.#accounts.get(logicalSlotId);
|
|
236
|
+
if (account === undefined) {
|
|
237
|
+
this.#accounts.set(logicalSlotId, initialAccount(cycleStartMs));
|
|
238
|
+
this.#dirty = true;
|
|
239
|
+
return { cycleStartMs, cycleReset: false };
|
|
240
|
+
}
|
|
241
|
+
if (account.cycleStartMs === cycleStartMs) {
|
|
242
|
+
return { cycleStartMs, cycleReset: false };
|
|
243
|
+
}
|
|
244
|
+
this.#accounts.set(logicalSlotId, { ...account, cycleBytes: 0, cycleStartMs });
|
|
245
|
+
this.#dirty = true;
|
|
246
|
+
return { cycleStartMs, cycleReset: true };
|
|
247
|
+
}
|
|
248
|
+
|
|
195
249
|
/** Flush unpersisted state immediately — the shutdown hook (bounds loss to ≤1 min). */
|
|
196
250
|
async flush(): Promise<void> {
|
|
197
251
|
if (this.#dirty) {
|
|
@@ -3,13 +3,22 @@
|
|
|
3
3
|
// The single most safety-critical constraint in the package (Must-NOT-Have: "no MM
|
|
4
4
|
// Simple.Connect / CreateBearer / Bearer.Connect calls ever"). Interfaces are
|
|
5
5
|
// erased at runtime, so the enforcement is a source scan: every port `.ts` file is
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
// "bearer". Adding such a
|
|
6
|
+
// parsed with the TypeScript compiler API and each DECLARED member of an interface,
|
|
7
|
+
// type literal, or class is checked for a name that is `connect`, `simpleConnect`,
|
|
8
|
+
// or contains "bearer". Adding such a member to any port fails this test — and CI.
|
|
9
|
+
//
|
|
10
|
+
// The detector walks the real AST rather than the source text, so it catches shapes
|
|
11
|
+
// a naive regex cannot: method signatures, property signatures typed as function
|
|
12
|
+
// types, get/set accessors, optional members, and quoted or computed string-literal
|
|
13
|
+
// member names (`'connect'()`, `['connect']()`) all resolve to their true name. It
|
|
14
|
+
// only ever inspects member NAMES on those three declaration kinds — never string
|
|
15
|
+
// literal values, value-position object-literal keys, comments, local variables, or
|
|
16
|
+
// call expressions — so prose mentions of "bearer" and value keys never trip it.
|
|
9
17
|
|
|
10
18
|
import { expect, test } from 'bun:test';
|
|
11
19
|
import { readdirSync } from 'node:fs';
|
|
12
20
|
import { join } from 'node:path';
|
|
21
|
+
import * as ts from 'typescript';
|
|
13
22
|
|
|
14
23
|
const portsDir = import.meta.dir;
|
|
15
24
|
|
|
@@ -18,11 +27,82 @@ function isForbiddenMethodName(name: string): boolean {
|
|
|
18
27
|
return lower === 'connect' || lower === 'simpleconnect' || lower.includes('bearer');
|
|
19
28
|
}
|
|
20
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Resolve a declared member's name node to its static string name, or `undefined`
|
|
32
|
+
* when the name is not statically knowable (e.g. a computed `[Symbol.iterator]` or a
|
|
33
|
+
* computed reference to a non-literal identifier). Plain identifiers, private names,
|
|
34
|
+
* string / numeric literals, and computed names wrapping a string / numeric literal
|
|
35
|
+
* (`['connect']`) all resolve to their real text.
|
|
36
|
+
*/
|
|
37
|
+
function resolveMemberName(name: ts.Node): string | undefined {
|
|
38
|
+
if (ts.isIdentifier(name) || ts.isPrivateIdentifier(name)) {
|
|
39
|
+
return name.text;
|
|
40
|
+
}
|
|
41
|
+
if (ts.isStringLiteralLike(name) || ts.isNumericLiteral(name)) {
|
|
42
|
+
return name.text;
|
|
43
|
+
}
|
|
44
|
+
if (ts.isComputedPropertyName(name)) {
|
|
45
|
+
const { expression } = name;
|
|
46
|
+
if (ts.isStringLiteralLike(expression) || ts.isNumericLiteral(expression)) {
|
|
47
|
+
return expression.text;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Every DECLARED member name of an interface, type literal, or class in `source`,
|
|
55
|
+
* parsed via the TypeScript compiler API. Value-position object-literal keys, string
|
|
56
|
+
* literal values, comments, local variables, and call expressions are all excluded —
|
|
57
|
+
* only the port's actual type surface is inspected. Method signatures, property
|
|
58
|
+
* signatures (including arrow-typed properties), get/set accessors, optional members,
|
|
59
|
+
* and quoted / computed string-literal member names are all resolved to their name.
|
|
60
|
+
*/
|
|
61
|
+
function declaredMemberNames(source: string): string[] {
|
|
62
|
+
const sourceFile = ts.createSourceFile(
|
|
63
|
+
'port.ts',
|
|
64
|
+
source,
|
|
65
|
+
ts.ScriptTarget.Latest,
|
|
66
|
+
/* setParentNodes */ true,
|
|
67
|
+
);
|
|
68
|
+
const names: string[] = [];
|
|
69
|
+
|
|
70
|
+
const collectMembers = (members: readonly (ts.TypeElement | ts.ClassElement)[]): void => {
|
|
71
|
+
for (const member of members) {
|
|
72
|
+
if (member.name === undefined) {
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
const resolved = resolveMemberName(member.name);
|
|
76
|
+
if (resolved !== undefined) {
|
|
77
|
+
names.push(resolved);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const visit = (node: ts.Node): void => {
|
|
83
|
+
if (
|
|
84
|
+
ts.isInterfaceDeclaration(node) ||
|
|
85
|
+
ts.isClassDeclaration(node) ||
|
|
86
|
+
ts.isClassExpression(node) ||
|
|
87
|
+
ts.isTypeLiteralNode(node)
|
|
88
|
+
) {
|
|
89
|
+
collectMembers(node.members);
|
|
90
|
+
}
|
|
91
|
+
ts.forEachChild(node, visit);
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
visit(sourceFile);
|
|
95
|
+
return names;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// The retired regex detector — kept ONLY so the differential test below can prove,
|
|
99
|
+
// by running it, that the AST rebuild is a strict improvement (it caught just one of
|
|
100
|
+
// the seven forbidden shapes). Not used by any real-port scan; do not reintroduce.
|
|
21
101
|
function stripComments(source: string): string {
|
|
22
102
|
return source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/[^\n]*/g, '');
|
|
23
103
|
}
|
|
24
104
|
|
|
25
|
-
function
|
|
105
|
+
function legacyRegexMemberNames(source: string): string[] {
|
|
26
106
|
const names: string[] = [];
|
|
27
107
|
const methodDecl = /(?:^|\n)\s*([a-zA-Z_$][\w$]*)\s*[<(]/g;
|
|
28
108
|
let match = methodDecl.exec(source);
|
|
@@ -44,14 +124,14 @@ function portSourceFiles(): string[] {
|
|
|
44
124
|
|
|
45
125
|
test('no port source declares a bearer / connect method', async () => {
|
|
46
126
|
for (const file of portSourceFiles()) {
|
|
47
|
-
const source =
|
|
127
|
+
const source = await Bun.file(join(portsDir, file)).text();
|
|
48
128
|
const forbidden = declaredMemberNames(source).filter(isForbiddenMethodName);
|
|
49
|
-
expect(forbidden, `forbidden
|
|
129
|
+
expect(forbidden, `forbidden member(s) in ${file}: ${forbidden.join(', ')}`).toEqual([]);
|
|
50
130
|
}
|
|
51
131
|
});
|
|
52
132
|
|
|
53
133
|
test('the ModemManager port source declares the expected non-bearer mutations', async () => {
|
|
54
|
-
const source =
|
|
134
|
+
const source = await Bun.file(join(portsDir, 'modem-manager.ts')).text();
|
|
55
135
|
const names = declaredMemberNames(source);
|
|
56
136
|
for (const expected of [
|
|
57
137
|
'setRadioModes',
|
|
@@ -66,15 +146,96 @@ test('the ModemManager port source declares the expected non-bearer mutations',
|
|
|
66
146
|
}
|
|
67
147
|
});
|
|
68
148
|
|
|
69
|
-
test('the detector
|
|
149
|
+
test('the detector catches every forbidden member shape (self-test)', () => {
|
|
150
|
+
// Each line is a DISTINCT declaration shape the AST resolver must catch: a plain
|
|
151
|
+
// method, a computed string-literal method, a quoted string-literal method, an
|
|
152
|
+
// optional method, a get accessor, a set accessor, and an arrow-typed property.
|
|
153
|
+
// `setRadioModes` is a legitimate mutation that must pass through unflagged.
|
|
70
154
|
const rogue = `
|
|
71
|
-
export interface
|
|
155
|
+
export interface RogueSurface {
|
|
72
156
|
connect(): Promise<void>;
|
|
73
|
-
|
|
74
|
-
|
|
157
|
+
['simpleConnect'](): void;
|
|
158
|
+
'createBearer'(): void;
|
|
159
|
+
bearerReset?(): void;
|
|
160
|
+
get bearerState(): number;
|
|
161
|
+
set bearerTarget(value: number);
|
|
162
|
+
bearerHook: () => Promise<void>;
|
|
163
|
+
setRadioModes(): Promise<void>;
|
|
164
|
+
}
|
|
165
|
+
`;
|
|
166
|
+
const flagged = declaredMemberNames(rogue).filter(isForbiddenMethodName).sort();
|
|
167
|
+
expect(flagged).toEqual([
|
|
168
|
+
'bearerHook',
|
|
169
|
+
'bearerReset',
|
|
170
|
+
'bearerState',
|
|
171
|
+
'bearerTarget',
|
|
172
|
+
'connect',
|
|
173
|
+
'createBearer',
|
|
174
|
+
'simpleConnect',
|
|
175
|
+
]);
|
|
176
|
+
// The acceptance bar for the rebuild: at least seven distinct forbidden shapes.
|
|
177
|
+
expect(flagged.length).toBeGreaterThanOrEqual(7);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test('the detector ignores string values, value-position keys, and non-forbidden members', () => {
|
|
181
|
+
// Three negative fixtures that must NOT trip the detector:
|
|
182
|
+
// 1. `reconnectPolicy` — a real interface member whose name merely CONTAINS the
|
|
183
|
+
// substring "connect"; the predicate matches "connect" exactly, not as a
|
|
184
|
+
// substring, so it must pass (proves the predicate is not overly broad).
|
|
185
|
+
// 2. `description: '…bearer…'` — a member whose string-literal VALUE contains
|
|
186
|
+
// "bearer"; only member NAMES are inspected, never string content.
|
|
187
|
+
// 3. `bearerConnect: true` — a key in a value-position object literal, not a
|
|
188
|
+
// type-level member declaration; the AST never walks object-literal keys.
|
|
189
|
+
const negative = `
|
|
190
|
+
interface LegitPort {
|
|
191
|
+
reconnectPolicy: RetryPolicy;
|
|
75
192
|
setRadioModes(): Promise<void>;
|
|
193
|
+
description: 'manages bearer state internally';
|
|
194
|
+
}
|
|
195
|
+
const runtimeConfig = {
|
|
196
|
+
bearerConnect: true,
|
|
197
|
+
description: 'manages bearer state internally',
|
|
198
|
+
};
|
|
199
|
+
`;
|
|
200
|
+
const members = declaredMemberNames(negative);
|
|
201
|
+
expect(members).toContain('reconnectPolicy');
|
|
202
|
+
expect(members).not.toContain('bearerConnect');
|
|
203
|
+
expect(members.filter(isForbiddenMethodName)).toEqual([]);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test('the AST detector catches member shapes the legacy regex missed (differential)', () => {
|
|
207
|
+
// The same rogue surface, run through BOTH detectors. The retired regex only ever
|
|
208
|
+
// matched an identifier immediately followed by `(` or `<` at a line start, so it
|
|
209
|
+
// caught the single plain method and missed the other six shapes. The AST walk
|
|
210
|
+
// catches all seven — the strict improvement the rebuild delivers.
|
|
211
|
+
const rogue = `
|
|
212
|
+
export interface RogueSurface {
|
|
213
|
+
connect(): Promise<void>;
|
|
214
|
+
['simpleConnect'](): void;
|
|
215
|
+
'createBearer'(): void;
|
|
216
|
+
bearerReset?(): void;
|
|
217
|
+
get bearerState(): number;
|
|
218
|
+
set bearerTarget(value: number);
|
|
219
|
+
bearerHook: () => Promise<void>;
|
|
76
220
|
}
|
|
77
221
|
`;
|
|
78
|
-
const
|
|
79
|
-
|
|
222
|
+
const legacyFlagged = legacyRegexMemberNames(stripComments(rogue))
|
|
223
|
+
.filter(isForbiddenMethodName)
|
|
224
|
+
.sort();
|
|
225
|
+
const astFlagged = declaredMemberNames(rogue).filter(isForbiddenMethodName).sort();
|
|
226
|
+
|
|
227
|
+
// The regex caught only the plain method.
|
|
228
|
+
expect(legacyFlagged).toEqual(['connect']);
|
|
229
|
+
// The AST catches every shape the regex missed, and then some.
|
|
230
|
+
for (const missed of [
|
|
231
|
+
'bearerHook',
|
|
232
|
+
'bearerReset',
|
|
233
|
+
'bearerState',
|
|
234
|
+
'bearerTarget',
|
|
235
|
+
'createBearer',
|
|
236
|
+
'simpleConnect',
|
|
237
|
+
]) {
|
|
238
|
+
expect(legacyFlagged).not.toContain(missed);
|
|
239
|
+
expect(astFlagged).toContain(missed);
|
|
240
|
+
}
|
|
80
241
|
});
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// Method-call dispatch and reply correlation for the D-Bus transport seam.
|
|
2
|
+
//
|
|
3
|
+
// A call marshals its body through the codec, registers a pending record so a bus drop can
|
|
4
|
+
// reject it, and installs a per-call reply callback guarded by a `done` flag — so a reply
|
|
5
|
+
// that arrives after the call already timed out or the connection dropped is silently
|
|
6
|
+
// ignored rather than mis-correlated onto a stale promise. `rejectAll` mass-rejects every
|
|
7
|
+
// in-flight call when the connection drops.
|
|
8
|
+
|
|
9
|
+
import { decodeBody, encodeBody } from './codec';
|
|
10
|
+
import { messageType, type RawBus, type RawMessage, type ReplyContext } from './dbus-native';
|
|
11
|
+
import { DisconnectedError, TransportError } from './errors';
|
|
12
|
+
import type { DbusValue, MethodCall, MethodReply } from './types';
|
|
13
|
+
|
|
14
|
+
export const DEFAULT_CALL_TIMEOUT_MS = 30_000;
|
|
15
|
+
|
|
16
|
+
interface PendingCall {
|
|
17
|
+
settle(): void;
|
|
18
|
+
reject(error: unknown): void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class CallDispatcher {
|
|
22
|
+
readonly #pending = new Set<PendingCall>();
|
|
23
|
+
readonly #callTimeoutMs: number;
|
|
24
|
+
|
|
25
|
+
constructor(callTimeoutMs: number) {
|
|
26
|
+
this.#callTimeoutMs = callTimeoutMs;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async call(bus: RawBus | null, connected: boolean, call: MethodCall): Promise<MethodReply> {
|
|
30
|
+
if (!connected || bus === null) {
|
|
31
|
+
throw new DisconnectedError('cannot call method: transport not connected');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const signature = call.signature ?? '';
|
|
35
|
+
const args = call.args ?? [];
|
|
36
|
+
const message: RawMessage = {
|
|
37
|
+
type: messageType.methodCall,
|
|
38
|
+
destination: call.destination,
|
|
39
|
+
path: call.path,
|
|
40
|
+
interface: call.interface,
|
|
41
|
+
member: call.member,
|
|
42
|
+
};
|
|
43
|
+
if (signature.length > 0) {
|
|
44
|
+
// Throws UnsupportedSignatureError / BigIntRequiredError before anything hits
|
|
45
|
+
// the wire.
|
|
46
|
+
message.signature = signature;
|
|
47
|
+
message.body = encodeBody(signature, args);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const timeoutMs = call.timeoutMs ?? this.#callTimeoutMs;
|
|
51
|
+
const pendingSet = this.#pending;
|
|
52
|
+
return new Promise<MethodReply>((resolve, reject) => {
|
|
53
|
+
let done = false;
|
|
54
|
+
const pending: PendingCall = {
|
|
55
|
+
settle: finish,
|
|
56
|
+
reject: (error) => {
|
|
57
|
+
finish();
|
|
58
|
+
reject(error);
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
function finish(): void {
|
|
63
|
+
if (done) {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
done = true;
|
|
67
|
+
clearTimeout(timer);
|
|
68
|
+
pendingSet.delete(pending);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const timer = setTimeout(() => {
|
|
72
|
+
finish();
|
|
73
|
+
reject(
|
|
74
|
+
new TransportError(
|
|
75
|
+
`Method call ${call.interface}.${call.member} timed out after ${timeoutMs}ms`,
|
|
76
|
+
),
|
|
77
|
+
);
|
|
78
|
+
}, timeoutMs);
|
|
79
|
+
|
|
80
|
+
pendingSet.add(pending);
|
|
81
|
+
|
|
82
|
+
bus.invoke(
|
|
83
|
+
message,
|
|
84
|
+
function reply(this: ReplyContext, error: unknown, ...body: unknown[]): void {
|
|
85
|
+
if (done) {
|
|
86
|
+
// Reply arrived after timeout/disconnect already settled the promise — ignore.
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
finish();
|
|
90
|
+
if (error) {
|
|
91
|
+
reject(error instanceof Error ? error : new TransportError(String(error)));
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
try {
|
|
95
|
+
const replySignature = this.signature ?? '';
|
|
96
|
+
const decoded: DbusValue[] =
|
|
97
|
+
replySignature.length > 0 ? decodeBody(replySignature, body) : [];
|
|
98
|
+
resolve({ signature: replySignature, body: decoded });
|
|
99
|
+
} catch (decodeError) {
|
|
100
|
+
reject(decodeError);
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
);
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
rejectAll(cause: unknown): void {
|
|
108
|
+
for (const pending of this.#pending) {
|
|
109
|
+
pending.reject(cause);
|
|
110
|
+
}
|
|
111
|
+
this.#pending.clear();
|
|
112
|
+
}
|
|
113
|
+
}
|