@ceralive/modem-control 0.1.0 → 0.2.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/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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ceralive/modem-control",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Cellular modem control for CeraLive — ModemManager D-Bus backend, NetworkManager adapter, desired-state reconciler, USB composition-mode model, data-usage sampler.",
|
|
6
6
|
"license": "AGPL-3.0",
|
|
@@ -9,6 +9,14 @@
|
|
|
9
9
|
// `mm-managed`; a bare vendor-specific interface with no recognized driver is NOT a
|
|
10
10
|
// modem. `pending-modeswitch` is a DISTINCT state (a modem installer awaiting
|
|
11
11
|
// `usb_modeswitch`), never conflated with `unmanaged`.
|
|
12
|
+
//
|
|
13
|
+
// SCOPE — USB ONLY: the whole input here is a `UsbDeviceSnapshot`, a udev/sysfs view of a
|
|
14
|
+
// USB device. PCIe modems are out of scope by construction and get NO entry in this model —
|
|
15
|
+
// a PCI `vendor:device` pair is never smuggled in as a pseudo-USB identity. The Fibocom
|
|
16
|
+
// FM350 is the canonical example: it is a PCIe module (PCI `14c3:4d75`, bound by the
|
|
17
|
+
// `mtk_t7xx` driver on the `wwan`/`net` subsystems, with no USB VID:PID), so it is
|
|
18
|
+
// documented-deferred rather than classified here. See `docs/FM350-DECISION.md` for the
|
|
19
|
+
// evidence and the three-gate ledger behind that decision.
|
|
12
20
|
|
|
13
21
|
import type { CanonicalUsbMode, ExpectedDescriptors } from '../usb-mode';
|
|
14
22
|
|
|
@@ -22,6 +22,10 @@ describe('parseMmVersion', () => {
|
|
|
22
22
|
expect(parseMmVersion('1.24.0')).toEqual({ major: 1, minor: 24 });
|
|
23
23
|
});
|
|
24
24
|
|
|
25
|
+
test('parses the 1.24.2 FM350-fix release', () => {
|
|
26
|
+
expect(parseMmVersion('1.24.2')).toEqual({ major: 1, minor: 24 });
|
|
27
|
+
});
|
|
28
|
+
|
|
25
29
|
test('parses major.minor without a patch', () => {
|
|
26
30
|
expect(parseMmVersion('1.20')).toEqual({ major: 1, minor: 20 });
|
|
27
31
|
});
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
// Characterization tests for the transport seam's reconnect / call-correlation / ordering
|
|
2
|
+
// edges — the behaviour that a later refactor (splitting transport.ts into lifecycle,
|
|
3
|
+
// call-dispatch, and signal modules) MUST preserve byte-for-byte at the observable level.
|
|
4
|
+
//
|
|
5
|
+
// These pin CURRENT behaviour, not aspirational behaviour: each assertion records what the
|
|
6
|
+
// unsplit transport.ts actually does today. If the split changes any of these observable
|
|
7
|
+
// facts, one of these tests goes red — which is the whole point.
|
|
8
|
+
//
|
|
9
|
+
// Like reliability.test.ts, these run against dedicated private `dbus-daemon` instances
|
|
10
|
+
// (each test owns one) so a destructive kill/restart is safe. That also makes the file
|
|
11
|
+
// self-contained — it needs no outer `dbus-run-session`.
|
|
12
|
+
|
|
13
|
+
import { describe, expect, test } from 'bun:test';
|
|
14
|
+
import { createDbusTransport } from './index';
|
|
15
|
+
import { FAKE_IFACE, FAKE_PATH, startFakeService, TICK_MEMBER } from './test-support/fake-service';
|
|
16
|
+
import { PrivateBus } from './test-support/private-bus';
|
|
17
|
+
|
|
18
|
+
const HAS_DBUS_DAEMON = Bun.which('dbus-daemon') !== null;
|
|
19
|
+
|
|
20
|
+
const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
|
|
21
|
+
|
|
22
|
+
async function waitFor(predicate: () => boolean, timeoutMs: number, label: string): Promise<void> {
|
|
23
|
+
const deadline = Date.now() + timeoutMs;
|
|
24
|
+
while (Date.now() < deadline) {
|
|
25
|
+
if (predicate()) {
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
await sleep(10);
|
|
29
|
+
}
|
|
30
|
+
throw new Error(`timed out after ${timeoutMs}ms waiting for ${label}`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const tickSpec = { interface: FAKE_IFACE, member: TICK_MEMBER, path: FAKE_PATH };
|
|
34
|
+
|
|
35
|
+
describe.skipIf(!HAS_DBUS_DAEMON)('transport characterization', () => {
|
|
36
|
+
// (i) A call that times out, then the real reply lands AFTER the timeout already
|
|
37
|
+
// settled the promise. The late reply must be silently ignored (each `bus.invoke`
|
|
38
|
+
// owns its own reply closure, guarded by a `done` flag) — no crash, and no
|
|
39
|
+
// mis-correlation onto a later, unrelated call.
|
|
40
|
+
test('a reply arriving after the call already timed out is silently ignored', async () => {
|
|
41
|
+
const bus = new PrivateBus();
|
|
42
|
+
await bus.start();
|
|
43
|
+
const fake = await startFakeService({ socket: bus.socket });
|
|
44
|
+
const transport = createDbusTransport({ socket: bus.socket, reconnect: { enabled: false } });
|
|
45
|
+
let errorEvents = 0;
|
|
46
|
+
transport.on('error', () => {
|
|
47
|
+
errorEvents += 1;
|
|
48
|
+
});
|
|
49
|
+
await transport.connect();
|
|
50
|
+
|
|
51
|
+
// SlowPing replies after 400ms; the call's own timeout is 100ms, so it times out
|
|
52
|
+
// first and the reply becomes a "late" one 300ms later.
|
|
53
|
+
await expect(
|
|
54
|
+
transport.callMethod({
|
|
55
|
+
destination: fake.busName,
|
|
56
|
+
path: FAKE_PATH,
|
|
57
|
+
interface: FAKE_IFACE,
|
|
58
|
+
member: 'SlowPing',
|
|
59
|
+
signature: 'u',
|
|
60
|
+
args: [400],
|
|
61
|
+
timeoutMs: 100,
|
|
62
|
+
}),
|
|
63
|
+
).rejects.toThrow('timed out after 100ms');
|
|
64
|
+
|
|
65
|
+
// Wait well past the 400ms reply so the ignored late reply has actually been
|
|
66
|
+
// delivered to (and dropped by) the settled callback.
|
|
67
|
+
await sleep(500);
|
|
68
|
+
|
|
69
|
+
// No crash, no error event, still connected — the late reply disturbed nothing.
|
|
70
|
+
expect(errorEvents).toBe(0);
|
|
71
|
+
expect(transport.isConnected()).toBe(true);
|
|
72
|
+
|
|
73
|
+
// And the pending machinery is intact: a fresh call correlates to its OWN reply,
|
|
74
|
+
// proving the late reply was not mis-delivered to a later promise.
|
|
75
|
+
const reply = await transport.callMethod({
|
|
76
|
+
destination: fake.busName,
|
|
77
|
+
path: FAKE_PATH,
|
|
78
|
+
interface: FAKE_IFACE,
|
|
79
|
+
member: 'Ping',
|
|
80
|
+
});
|
|
81
|
+
expect(reply.body[0]).toBe('pong');
|
|
82
|
+
|
|
83
|
+
await transport.disconnect();
|
|
84
|
+
await fake.stop();
|
|
85
|
+
await bus.stop();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// (ii) A bus drop while a call is in flight. The in-flight call must reject, and the
|
|
89
|
+
// observed ordering is pinned: the `disconnected` event is delivered BEFORE the call
|
|
90
|
+
// rejection is observed. (In `#handleDrop`, pending calls are rejected and then
|
|
91
|
+
// `disconnected` is emitted synchronously — but a promise rejection is observed on a
|
|
92
|
+
// microtask, so the synchronous event listener runs first.)
|
|
93
|
+
test('a mid-call bus drop rejects the in-flight call after emitting disconnected', async () => {
|
|
94
|
+
const bus = new PrivateBus();
|
|
95
|
+
await bus.start();
|
|
96
|
+
const fake = await startFakeService({ socket: bus.socket });
|
|
97
|
+
const transport = createDbusTransport({ socket: bus.socket, reconnect: { enabled: false } });
|
|
98
|
+
|
|
99
|
+
const order: string[] = [];
|
|
100
|
+
let rejection: unknown = null;
|
|
101
|
+
transport.on('disconnected', () => order.push('disconnected'));
|
|
102
|
+
await transport.connect();
|
|
103
|
+
|
|
104
|
+
// A call that will never get a reply — the bus dies under it.
|
|
105
|
+
const call = transport
|
|
106
|
+
.callMethod({
|
|
107
|
+
destination: fake.busName,
|
|
108
|
+
path: FAKE_PATH,
|
|
109
|
+
interface: FAKE_IFACE,
|
|
110
|
+
member: 'SlowPing',
|
|
111
|
+
signature: 'u',
|
|
112
|
+
args: [5000],
|
|
113
|
+
})
|
|
114
|
+
.catch((error: unknown) => {
|
|
115
|
+
order.push('call-rejected');
|
|
116
|
+
rejection = error;
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// Give the call time to reach the wire, then drop the bus under it.
|
|
120
|
+
await sleep(30);
|
|
121
|
+
bus.kill();
|
|
122
|
+
|
|
123
|
+
await waitFor(
|
|
124
|
+
() => order.includes('disconnected') && order.includes('call-rejected'),
|
|
125
|
+
5000,
|
|
126
|
+
'disconnected + call rejection',
|
|
127
|
+
);
|
|
128
|
+
await call;
|
|
129
|
+
|
|
130
|
+
// Pinned ordering: the event precedes the observed rejection.
|
|
131
|
+
expect(order).toEqual(['disconnected', 'call-rejected']);
|
|
132
|
+
// Pinned rejection type: a DisconnectedError (the connection-end drop cause).
|
|
133
|
+
expect(rejection).toBeInstanceOf(Error);
|
|
134
|
+
expect((rejection as Error).name).toBe('DisconnectedError');
|
|
135
|
+
|
|
136
|
+
await transport.disconnect();
|
|
137
|
+
// The fake died with its bus; stopping it would write to a closed stream. Leave it.
|
|
138
|
+
await bus.stop();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// (iii) A drop that arrives while a reconnect is already running. The reconnect loop is
|
|
142
|
+
// idempotent: `#handleDrop` early-returns whenever the state is already `disconnected`
|
|
143
|
+
// or `reconnecting`, so repeated low-level drop signals never spawn a second reconnect
|
|
144
|
+
// loop. The transport converges to a single connected state — exactly one `disconnected`
|
|
145
|
+
// and one `reconnected`, no error, and it is not wedged.
|
|
146
|
+
test('a drop during an in-flight reconnect does not double-schedule or wedge', async () => {
|
|
147
|
+
const bus = new PrivateBus();
|
|
148
|
+
await bus.start();
|
|
149
|
+
let fake = await startFakeService({ socket: bus.socket });
|
|
150
|
+
const transport = createDbusTransport({
|
|
151
|
+
socket: bus.socket,
|
|
152
|
+
reconnect: { initialDelayMs: 25, maxDelayMs: 100 },
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
const events: string[] = [];
|
|
156
|
+
let errorEvents = 0;
|
|
157
|
+
transport.on('disconnected', () => events.push('disconnected'));
|
|
158
|
+
transport.on('reconnected', () => events.push('reconnected'));
|
|
159
|
+
transport.on('error', () => {
|
|
160
|
+
errorEvents += 1;
|
|
161
|
+
});
|
|
162
|
+
await transport.connect();
|
|
163
|
+
|
|
164
|
+
const ticks: bigint[] = [];
|
|
165
|
+
const subscription = await transport.subscribeSignal(tickSpec, (event) => {
|
|
166
|
+
ticks.push(event.body[0] as bigint);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
fake.emitTick(11n);
|
|
170
|
+
await waitFor(() => ticks.includes(11n), 3000, 'pre-drop tick');
|
|
171
|
+
|
|
172
|
+
// Drop the bus and leave it down long enough for the reconnect loop to spin through
|
|
173
|
+
// several failed establish attempts before the bus returns.
|
|
174
|
+
bus.kill();
|
|
175
|
+
await waitFor(() => events.includes('disconnected'), 5000, 'disconnected');
|
|
176
|
+
await sleep(150);
|
|
177
|
+
await bus.start();
|
|
178
|
+
await waitFor(() => events.includes('reconnected'), 15000, 'reconnected');
|
|
179
|
+
|
|
180
|
+
// A settle window to catch any spurious extra event from a double-scheduled loop.
|
|
181
|
+
await sleep(300);
|
|
182
|
+
|
|
183
|
+
expect(events.filter((event) => event === 'disconnected')).toHaveLength(1);
|
|
184
|
+
expect(events.filter((event) => event === 'reconnected')).toHaveLength(1);
|
|
185
|
+
expect(errorEvents).toBe(0);
|
|
186
|
+
|
|
187
|
+
// Not wedged: a fresh producer's signal flows through the auto-resubscribed rule.
|
|
188
|
+
fake = await startFakeService({ socket: bus.socket });
|
|
189
|
+
fake.emitTick(22n);
|
|
190
|
+
await waitFor(() => ticks.includes(22n), 8000, 'post-reconnect tick');
|
|
191
|
+
expect(transport.subscriptionCount()).toBe(1);
|
|
192
|
+
|
|
193
|
+
await subscription.unsubscribe();
|
|
194
|
+
await transport.disconnect();
|
|
195
|
+
await fake.stop();
|
|
196
|
+
await bus.stop();
|
|
197
|
+
}, 30000);
|
|
198
|
+
|
|
199
|
+
// (iv) A subscription added AND one removed while a reconnect is in progress. Because
|
|
200
|
+
// mutating a subscription while disconnected only touches the in-memory match-rule
|
|
201
|
+
// refcount (the bus call is skipped when not connected), and `#establish()` re-issues
|
|
202
|
+
// every live rule on reconnect, the refcounting must end up correct: the added
|
|
203
|
+
// subscription is registered (receives signals) and the removed one is not.
|
|
204
|
+
test('subscriptions mutated during reconnect end up correctly (un)registered', async () => {
|
|
205
|
+
const bus = new PrivateBus();
|
|
206
|
+
await bus.start();
|
|
207
|
+
const transport = createDbusTransport({
|
|
208
|
+
socket: bus.socket,
|
|
209
|
+
reconnect: { initialDelayMs: 25, maxDelayMs: 100 },
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
const events: string[] = [];
|
|
213
|
+
transport.on('disconnected', () => events.push('disconnected'));
|
|
214
|
+
transport.on('reconnected', () => events.push('reconnected'));
|
|
215
|
+
await transport.connect();
|
|
216
|
+
|
|
217
|
+
// `removed` is a path-filtered rule; `added` is a distinct (no-path) rule that still
|
|
218
|
+
// matches the same emitted Tick — so their match-rule strings differ and are tracked
|
|
219
|
+
// independently.
|
|
220
|
+
const removedTicks: bigint[] = [];
|
|
221
|
+
const addedTicks: bigint[] = [];
|
|
222
|
+
const removed = await transport.subscribeSignal(tickSpec, (event) => {
|
|
223
|
+
removedTicks.push(event.body[0] as bigint);
|
|
224
|
+
});
|
|
225
|
+
expect(transport.subscriptionCount()).toBe(1);
|
|
226
|
+
|
|
227
|
+
// Drop the bus; while the reconnect loop is running, mutate the subscription set.
|
|
228
|
+
bus.kill();
|
|
229
|
+
await waitFor(() => events.includes('disconnected'), 5000, 'disconnected');
|
|
230
|
+
|
|
231
|
+
await removed.unsubscribe();
|
|
232
|
+
const added = await transport.subscribeSignal(
|
|
233
|
+
{ interface: FAKE_IFACE, member: TICK_MEMBER },
|
|
234
|
+
(event) => {
|
|
235
|
+
addedTicks.push(event.body[0] as bigint);
|
|
236
|
+
},
|
|
237
|
+
);
|
|
238
|
+
expect(transport.subscriptionCount()).toBe(1);
|
|
239
|
+
|
|
240
|
+
// Bring the bus back; `#establish()` re-issues exactly the rules still in the
|
|
241
|
+
// refcount map — the `added` one, not the `removed` one.
|
|
242
|
+
await bus.start();
|
|
243
|
+
await waitFor(() => events.includes('reconnected'), 15000, 'reconnected');
|
|
244
|
+
|
|
245
|
+
const fake = await startFakeService({ socket: bus.socket });
|
|
246
|
+
fake.emitTick(33n);
|
|
247
|
+
await waitFor(() => addedTicks.includes(33n), 8000, 'added-subscription tick');
|
|
248
|
+
|
|
249
|
+
// Grace to prove the removed subscription genuinely receives nothing.
|
|
250
|
+
await sleep(200);
|
|
251
|
+
expect(addedTicks).toEqual([33n]);
|
|
252
|
+
expect(removedTicks).toEqual([]);
|
|
253
|
+
expect(transport.subscriptionCount()).toBe(1);
|
|
254
|
+
|
|
255
|
+
await added.unsubscribe();
|
|
256
|
+
await transport.disconnect();
|
|
257
|
+
await fake.stop();
|
|
258
|
+
await bus.stop();
|
|
259
|
+
}, 30000);
|
|
260
|
+
});
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// fallback `@particle/dbus-next`) must stay invisible to every caller.
|
|
7
7
|
|
|
8
8
|
import { expect, test } from 'bun:test';
|
|
9
|
+
import { readdirSync } from 'node:fs';
|
|
9
10
|
import { join } from 'node:path';
|
|
10
11
|
import * as transportPublic from './index';
|
|
11
12
|
|
|
@@ -26,11 +27,21 @@ test('the transport public entry does not import or re-export the D-Bus library'
|
|
|
26
27
|
});
|
|
27
28
|
|
|
28
29
|
test('only the quarantined facade imports the D-Bus library from production modules', async () => {
|
|
29
|
-
|
|
30
|
+
// Enumerate every non-test production module in transport/ dynamically, so a NEW module
|
|
31
|
+
// (e.g. a future transport split) that imports the library directly is caught — the old
|
|
32
|
+
// fixed list never knew about files it did not name. The importer set must be EXACTLY the
|
|
33
|
+
// sanctioned facade, `dbus-native.ts`.
|
|
34
|
+
const productionModules = readdirSync(transportDir).filter(
|
|
35
|
+
(name) => name.endsWith('.ts') && !name.endsWith('.test.ts'),
|
|
36
|
+
);
|
|
37
|
+
const importers: string[] = [];
|
|
30
38
|
for (const moduleName of productionModules) {
|
|
31
39
|
const source = await Bun.file(join(transportDir, moduleName)).text();
|
|
32
|
-
|
|
40
|
+
if (source.includes(LIBRARY_IMPORT)) {
|
|
41
|
+
importers.push(moduleName);
|
|
42
|
+
}
|
|
33
43
|
}
|
|
44
|
+
expect(importers.sort()).toEqual(['dbus-native.ts']);
|
|
34
45
|
});
|
|
35
46
|
|
|
36
47
|
test('the transport public surface exposes only the seam\u2019s own values', () => {
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// Signal subscription and match-rule tracking for the D-Bus transport seam.
|
|
2
|
+
//
|
|
3
|
+
// The transport's single persistent `message` listener fans out here: `dispatch` walks the
|
|
4
|
+
// live subscription registry and delivers each decoded signal to every matching listener.
|
|
5
|
+
// Match rules are refcounted so N subscriptions sharing a rule add/remove it on the bus
|
|
6
|
+
// exactly once, and `reissueRules` re-adds every live rule after a reconnect — so
|
|
7
|
+
// subscribing/unsubscribing never grows the connection's listener count (the 100-cycle
|
|
8
|
+
// leak check depends on this).
|
|
9
|
+
|
|
10
|
+
import { decodeBody } from './codec';
|
|
11
|
+
import { messageType, type RawBus, type RawMessage } from './dbus-native';
|
|
12
|
+
import type { DbusValue, SignalEvent, SignalListener, SignalSpec, Subscription } from './types';
|
|
13
|
+
|
|
14
|
+
// The live connection context the registry reads through. The transport supplies these so
|
|
15
|
+
// the registry always sees the current bus/connected state (which change across reconnects)
|
|
16
|
+
// rather than capturing a stale reference, and routes decode/listener failures to the
|
|
17
|
+
// transport's `error` event.
|
|
18
|
+
export interface SignalHost {
|
|
19
|
+
currentBus(): RawBus | null;
|
|
20
|
+
isConnected(): boolean;
|
|
21
|
+
emitError(error: unknown): void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface SubscriptionRecord {
|
|
25
|
+
readonly id: number;
|
|
26
|
+
readonly spec: SignalSpec;
|
|
27
|
+
readonly listener: SignalListener;
|
|
28
|
+
readonly rule: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function buildMatchRule(spec: SignalSpec): string {
|
|
32
|
+
const parts = [`type='signal'`, `interface='${spec.interface}'`, `member='${spec.member}'`];
|
|
33
|
+
if (spec.path !== undefined) {
|
|
34
|
+
parts.push(`path='${spec.path}'`);
|
|
35
|
+
}
|
|
36
|
+
if (spec.sender !== undefined) {
|
|
37
|
+
parts.push(`sender='${spec.sender}'`);
|
|
38
|
+
}
|
|
39
|
+
return parts.join(',');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function signalMatches(spec: SignalSpec, message: RawMessage): boolean {
|
|
43
|
+
if (message.interface !== spec.interface || message.member !== spec.member) {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
if (spec.path !== undefined && message.path !== spec.path) {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
if (spec.sender !== undefined && message.sender !== spec.sender) {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export class SignalRegistry {
|
|
56
|
+
readonly #host: SignalHost;
|
|
57
|
+
readonly #subscriptions = new Map<number, SubscriptionRecord>();
|
|
58
|
+
readonly #matchRuleRefcount = new Map<string, number>();
|
|
59
|
+
#nextSubId = 1;
|
|
60
|
+
|
|
61
|
+
constructor(host: SignalHost) {
|
|
62
|
+
this.#host = host;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async subscribe(spec: SignalSpec, listener: SignalListener): Promise<Subscription> {
|
|
66
|
+
const rule = buildMatchRule(spec);
|
|
67
|
+
const id = this.#nextSubId++;
|
|
68
|
+
this.#subscriptions.set(id, { id, spec, listener, rule });
|
|
69
|
+
await this.#addMatchRule(rule);
|
|
70
|
+
|
|
71
|
+
let removed = false;
|
|
72
|
+
return {
|
|
73
|
+
unsubscribe: async (): Promise<void> => {
|
|
74
|
+
if (removed) {
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
removed = true;
|
|
78
|
+
this.#subscriptions.delete(id);
|
|
79
|
+
await this.#removeMatchRule(rule);
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
count(): number {
|
|
85
|
+
return this.#subscriptions.size;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Re-issue every live match rule against a freshly established bus so a reconnect
|
|
89
|
+
// resubscribes transparently. Called by `#establish` before it swaps in the new bus, so
|
|
90
|
+
// the fresh bus is passed in explicitly rather than read from the host.
|
|
91
|
+
async reissueRules(bus: RawBus): Promise<void> {
|
|
92
|
+
for (const rule of this.#matchRuleRefcount.keys()) {
|
|
93
|
+
await bus.addMatch(rule);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
dispatch(message: RawMessage): void {
|
|
98
|
+
if (message.type !== messageType.signal) {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
for (const record of this.#subscriptions.values()) {
|
|
102
|
+
if (!signalMatches(record.spec, message)) {
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
let body: DbusValue[];
|
|
106
|
+
try {
|
|
107
|
+
const signature = message.signature ?? '';
|
|
108
|
+
body = signature.length > 0 ? decodeBody(signature, message.body ?? []) : [];
|
|
109
|
+
} catch (error) {
|
|
110
|
+
this.#host.emitError(error);
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
const event: SignalEvent = {
|
|
114
|
+
path: message.path ?? '',
|
|
115
|
+
interface: message.interface ?? '',
|
|
116
|
+
member: message.member ?? '',
|
|
117
|
+
sender: message.sender,
|
|
118
|
+
signature: message.signature ?? '',
|
|
119
|
+
body,
|
|
120
|
+
};
|
|
121
|
+
try {
|
|
122
|
+
record.listener(event);
|
|
123
|
+
} catch (error) {
|
|
124
|
+
this.#host.emitError(error);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async #addMatchRule(rule: string): Promise<void> {
|
|
130
|
+
const current = this.#matchRuleRefcount.get(rule) ?? 0;
|
|
131
|
+
this.#matchRuleRefcount.set(rule, current + 1);
|
|
132
|
+
const bus = this.#host.currentBus();
|
|
133
|
+
if (current === 0 && this.#host.isConnected() && bus) {
|
|
134
|
+
await bus.addMatch(rule);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async #removeMatchRule(rule: string): Promise<void> {
|
|
139
|
+
const current = this.#matchRuleRefcount.get(rule) ?? 0;
|
|
140
|
+
if (current <= 1) {
|
|
141
|
+
this.#matchRuleRefcount.delete(rule);
|
|
142
|
+
const bus = this.#host.currentBus();
|
|
143
|
+
if (current === 1 && this.#host.isConnected() && bus) {
|
|
144
|
+
await bus.removeMatch(rule).catch(() => undefined);
|
|
145
|
+
}
|
|
146
|
+
} else {
|
|
147
|
+
this.#matchRuleRefcount.set(rule, current - 1);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
@@ -106,16 +106,40 @@ export async function startFakeService(options: FakeServiceOptions): Promise<Fak
|
|
|
106
106
|
// surface an unhandled EventEmitter 'error' from this helper's dead connection.
|
|
107
107
|
bus.connection.on('error', () => undefined);
|
|
108
108
|
|
|
109
|
+
// A reconnect/drop test kills the bus and ABANDONS this fake (stopping it would itself
|
|
110
|
+
// write to the closed stream). Any reply the library still owes — e.g. a SlowPing whose
|
|
111
|
+
// delay has not elapsed — would then be written to that dead stream when its timer
|
|
112
|
+
// fires, throwing "Can't write a message to a closed stream" ASYNCHRONOUSLY, seconds
|
|
113
|
+
// later, inside whatever unrelated test happens to be running. Track the reply timers
|
|
114
|
+
// and cancel them the instant the connection stream ends, so a dead fake never writes.
|
|
115
|
+
const pendingReplyTimers = new Set<ReturnType<typeof setTimeout>>();
|
|
116
|
+
const clearPendingReplies = (): void => {
|
|
117
|
+
for (const timer of pendingReplyTimers) {
|
|
118
|
+
clearTimeout(timer);
|
|
119
|
+
}
|
|
120
|
+
pendingReplyTimers.clear();
|
|
121
|
+
};
|
|
122
|
+
bus.connection.on('end', clearPendingReplies);
|
|
123
|
+
|
|
109
124
|
const define = (member: string, impl: MethodImpl, resultSignature: string): void => {
|
|
110
125
|
bus.setMethodCallHandler(FAKE_PATH, FAKE_IFACE, member, [impl, resultSignature]);
|
|
111
126
|
};
|
|
112
127
|
|
|
113
128
|
define('Ping', () => 'pong', 's');
|
|
114
129
|
// The library awaits a Promise returned by a handler, so this replies after a delay —
|
|
115
|
-
// used to prove a late reply still resolves the caller's method call.
|
|
130
|
+
// used to prove a late reply still resolves the caller's method call. The timer is
|
|
131
|
+
// tracked so a bus drop before it fires cancels the owed reply instead of writing it
|
|
132
|
+
// to a closed stream.
|
|
116
133
|
define(
|
|
117
134
|
'SlowPing',
|
|
118
|
-
(delayMs) =>
|
|
135
|
+
(delayMs) =>
|
|
136
|
+
new Promise((resolve) => {
|
|
137
|
+
const timer = setTimeout(() => {
|
|
138
|
+
pendingReplyTimers.delete(timer);
|
|
139
|
+
resolve('pong');
|
|
140
|
+
}, Number(delayMs));
|
|
141
|
+
pendingReplyTimers.add(timer);
|
|
142
|
+
}),
|
|
119
143
|
's',
|
|
120
144
|
);
|
|
121
145
|
define('GetManagedObjects', () => managedObjectsValue(), 'a{oa{sa{sv}}}');
|
|
@@ -137,6 +161,7 @@ export async function startFakeService(options: FakeServiceOptions): Promise<Fak
|
|
|
137
161
|
bus.sendSignal(FAKE_PATH, FAKE_IFACE, TICK_MEMBER, 't', [seq.toString()]);
|
|
138
162
|
},
|
|
139
163
|
async stop(): Promise<void> {
|
|
164
|
+
clearPendingReplies();
|
|
140
165
|
await bus.disconnect().catch(() => undefined);
|
|
141
166
|
},
|
|
142
167
|
};
|
|
@@ -1,29 +1,26 @@
|
|
|
1
1
|
// The D-Bus transport seam implementation.
|
|
2
2
|
//
|
|
3
|
-
// Wraps `@httptoolkit/dbus-native` behind the `DbusTransport` interface
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// subscription
|
|
7
|
-
// listener count
|
|
3
|
+
// Wraps `@httptoolkit/dbus-native` behind the `DbusTransport` interface. This module owns the
|
|
4
|
+
// connection lifecycle — handshake, a reconnect loop that re-issues every match rule after a
|
|
5
|
+
// bus restart, and teardown — and delegates method-call dispatch to `./calls` and signal
|
|
6
|
+
// subscription/match-rule tracking to `./signals`. Its single `message` listener fans out to
|
|
7
|
+
// the signal registry, so subscribing never grows the listener count (the 100-cycle leak check).
|
|
8
8
|
|
|
9
9
|
import { EventEmitter } from 'node:events';
|
|
10
|
-
import {
|
|
10
|
+
import { CallDispatcher, DEFAULT_CALL_TIMEOUT_MS } from './calls';
|
|
11
11
|
import {
|
|
12
12
|
type CreateClientOptions,
|
|
13
13
|
createClient,
|
|
14
|
-
messageType,
|
|
15
14
|
type RawBus,
|
|
16
15
|
type RawMessage,
|
|
17
|
-
type ReplyContext,
|
|
18
16
|
} from './dbus-native';
|
|
19
17
|
import { DisconnectedError, TransportError } from './errors';
|
|
18
|
+
import { SignalRegistry } from './signals';
|
|
20
19
|
import type {
|
|
21
20
|
DbusTransport,
|
|
22
21
|
DbusTransportOptions,
|
|
23
|
-
DbusValue,
|
|
24
22
|
MethodCall,
|
|
25
23
|
MethodReply,
|
|
26
|
-
SignalEvent,
|
|
27
24
|
SignalListener,
|
|
28
25
|
SignalSpec,
|
|
29
26
|
Subscription,
|
|
@@ -39,19 +36,6 @@ interface ResolvedReconnect {
|
|
|
39
36
|
readonly maxAttempts: number;
|
|
40
37
|
}
|
|
41
38
|
|
|
42
|
-
interface SubscriptionRecord {
|
|
43
|
-
readonly id: number;
|
|
44
|
-
readonly spec: SignalSpec;
|
|
45
|
-
readonly listener: SignalListener;
|
|
46
|
-
readonly rule: string;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
interface PendingCall {
|
|
50
|
-
settle(): void;
|
|
51
|
-
reject(error: unknown): void;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
const DEFAULT_CALL_TIMEOUT_MS = 30_000;
|
|
55
39
|
// Bound a single connect/auth attempt so a stalled handshake cannot freeze the reconnect
|
|
56
40
|
// loop. A local unix-socket D-Bus connect completes in milliseconds; 2s is ample headroom
|
|
57
41
|
// while keeping reconnect responsive after a bus restart.
|
|
@@ -65,46 +49,19 @@ const DEFAULT_RECONNECT: ResolvedReconnect = {
|
|
|
65
49
|
|
|
66
50
|
const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
|
|
67
51
|
|
|
68
|
-
function buildMatchRule(spec: SignalSpec): string {
|
|
69
|
-
const parts = [`type='signal'`, `interface='${spec.interface}'`, `member='${spec.member}'`];
|
|
70
|
-
if (spec.path !== undefined) {
|
|
71
|
-
parts.push(`path='${spec.path}'`);
|
|
72
|
-
}
|
|
73
|
-
if (spec.sender !== undefined) {
|
|
74
|
-
parts.push(`sender='${spec.sender}'`);
|
|
75
|
-
}
|
|
76
|
-
return parts.join(',');
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
function signalMatches(spec: SignalSpec, message: RawMessage): boolean {
|
|
80
|
-
if (message.interface !== spec.interface || message.member !== spec.member) {
|
|
81
|
-
return false;
|
|
82
|
-
}
|
|
83
|
-
if (spec.path !== undefined && message.path !== spec.path) {
|
|
84
|
-
return false;
|
|
85
|
-
}
|
|
86
|
-
if (spec.sender !== undefined && message.sender !== spec.sender) {
|
|
87
|
-
return false;
|
|
88
|
-
}
|
|
89
|
-
return true;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
52
|
class DbusTransportImpl implements DbusTransport {
|
|
93
53
|
readonly #options: DbusTransportOptions;
|
|
94
54
|
readonly #reconnect: ResolvedReconnect;
|
|
95
|
-
readonly #callTimeoutMs: number;
|
|
96
55
|
readonly #emitter = new EventEmitter();
|
|
97
|
-
readonly #
|
|
98
|
-
readonly #
|
|
99
|
-
readonly #pending = new Set<PendingCall>();
|
|
56
|
+
readonly #calls: CallDispatcher;
|
|
57
|
+
readonly #signals: SignalRegistry;
|
|
100
58
|
|
|
101
59
|
#bus: RawBus | null = null;
|
|
102
60
|
#state: State = 'idle';
|
|
103
61
|
#closing = false;
|
|
104
|
-
#nextSubId = 1;
|
|
105
62
|
|
|
106
63
|
// Bound once so the same references can be detached from a dead connection.
|
|
107
|
-
readonly #onMessage = (message: RawMessage): void => this.#
|
|
64
|
+
readonly #onMessage = (message: RawMessage): void => this.#signals.dispatch(message);
|
|
108
65
|
readonly #onConnectionError = (cause: unknown): void =>
|
|
109
66
|
this.#handleDrop(cause instanceof Error ? cause : new DisconnectedError(String(cause)));
|
|
110
67
|
readonly #onConnectionEnd = (): void =>
|
|
@@ -112,7 +69,12 @@ class DbusTransportImpl implements DbusTransport {
|
|
|
112
69
|
|
|
113
70
|
constructor(options: DbusTransportOptions) {
|
|
114
71
|
this.#options = options;
|
|
115
|
-
this.#
|
|
72
|
+
this.#calls = new CallDispatcher(options.callTimeoutMs ?? DEFAULT_CALL_TIMEOUT_MS);
|
|
73
|
+
this.#signals = new SignalRegistry({
|
|
74
|
+
currentBus: () => this.#bus,
|
|
75
|
+
isConnected: () => this.#state === 'connected',
|
|
76
|
+
emitError: (error) => this.#emitter.emit('error', error),
|
|
77
|
+
});
|
|
116
78
|
this.#reconnect = {
|
|
117
79
|
enabled: options.reconnect?.enabled ?? DEFAULT_RECONNECT.enabled,
|
|
118
80
|
initialDelayMs: options.reconnect?.initialDelayMs ?? DEFAULT_RECONNECT.initialDelayMs,
|
|
@@ -144,113 +106,23 @@ class DbusTransportImpl implements DbusTransport {
|
|
|
144
106
|
this.#state = 'closed';
|
|
145
107
|
const bus = this.#bus;
|
|
146
108
|
this.#bus = null;
|
|
147
|
-
this.#
|
|
109
|
+
this.#calls.rejectAll(new DisconnectedError('transport closed'));
|
|
148
110
|
if (bus) {
|
|
149
111
|
this.#quiesce(bus);
|
|
150
112
|
await bus.disconnect().catch(() => undefined);
|
|
151
113
|
}
|
|
152
114
|
}
|
|
153
115
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
if (this.#state !== 'connected' || bus === null) {
|
|
157
|
-
throw new DisconnectedError('cannot call method: transport not connected');
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
const signature = call.signature ?? '';
|
|
161
|
-
const args = call.args ?? [];
|
|
162
|
-
const message: RawMessage = {
|
|
163
|
-
type: messageType.methodCall,
|
|
164
|
-
destination: call.destination,
|
|
165
|
-
path: call.path,
|
|
166
|
-
interface: call.interface,
|
|
167
|
-
member: call.member,
|
|
168
|
-
};
|
|
169
|
-
if (signature.length > 0) {
|
|
170
|
-
// Throws UnsupportedSignatureError / BigIntRequiredError before anything hits
|
|
171
|
-
// the wire.
|
|
172
|
-
message.signature = signature;
|
|
173
|
-
message.body = encodeBody(signature, args);
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
const timeoutMs = call.timeoutMs ?? this.#callTimeoutMs;
|
|
177
|
-
const pendingSet = this.#pending;
|
|
178
|
-
return new Promise<MethodReply>((resolve, reject) => {
|
|
179
|
-
let done = false;
|
|
180
|
-
const pending: PendingCall = {
|
|
181
|
-
settle: finish,
|
|
182
|
-
reject: (error) => {
|
|
183
|
-
finish();
|
|
184
|
-
reject(error);
|
|
185
|
-
},
|
|
186
|
-
};
|
|
187
|
-
|
|
188
|
-
function finish(): void {
|
|
189
|
-
if (done) {
|
|
190
|
-
return;
|
|
191
|
-
}
|
|
192
|
-
done = true;
|
|
193
|
-
clearTimeout(timer);
|
|
194
|
-
pendingSet.delete(pending);
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
const timer = setTimeout(() => {
|
|
198
|
-
finish();
|
|
199
|
-
reject(
|
|
200
|
-
new TransportError(
|
|
201
|
-
`Method call ${call.interface}.${call.member} timed out after ${timeoutMs}ms`,
|
|
202
|
-
),
|
|
203
|
-
);
|
|
204
|
-
}, timeoutMs);
|
|
205
|
-
|
|
206
|
-
pendingSet.add(pending);
|
|
207
|
-
|
|
208
|
-
bus.invoke(
|
|
209
|
-
message,
|
|
210
|
-
function reply(this: ReplyContext, error: unknown, ...body: unknown[]): void {
|
|
211
|
-
if (done) {
|
|
212
|
-
// Reply arrived after timeout/disconnect already settled the promise — ignore.
|
|
213
|
-
return;
|
|
214
|
-
}
|
|
215
|
-
finish();
|
|
216
|
-
if (error) {
|
|
217
|
-
reject(error instanceof Error ? error : new TransportError(String(error)));
|
|
218
|
-
return;
|
|
219
|
-
}
|
|
220
|
-
try {
|
|
221
|
-
const replySignature = this.signature ?? '';
|
|
222
|
-
const decoded: DbusValue[] =
|
|
223
|
-
replySignature.length > 0 ? decodeBody(replySignature, body) : [];
|
|
224
|
-
resolve({ signature: replySignature, body: decoded });
|
|
225
|
-
} catch (decodeError) {
|
|
226
|
-
reject(decodeError);
|
|
227
|
-
}
|
|
228
|
-
},
|
|
229
|
-
);
|
|
230
|
-
});
|
|
116
|
+
callMethod(call: MethodCall): Promise<MethodReply> {
|
|
117
|
+
return this.#calls.call(this.#bus, this.#state === 'connected', call);
|
|
231
118
|
}
|
|
232
119
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
const id = this.#nextSubId++;
|
|
236
|
-
this.#subscriptions.set(id, { id, spec, listener, rule });
|
|
237
|
-
await this.#addMatchRule(rule);
|
|
238
|
-
|
|
239
|
-
let removed = false;
|
|
240
|
-
return {
|
|
241
|
-
unsubscribe: async (): Promise<void> => {
|
|
242
|
-
if (removed) {
|
|
243
|
-
return;
|
|
244
|
-
}
|
|
245
|
-
removed = true;
|
|
246
|
-
this.#subscriptions.delete(id);
|
|
247
|
-
await this.#removeMatchRule(rule);
|
|
248
|
-
},
|
|
249
|
-
};
|
|
120
|
+
subscribeSignal(spec: SignalSpec, listener: SignalListener): Promise<Subscription> {
|
|
121
|
+
return this.#signals.subscribe(spec, listener);
|
|
250
122
|
}
|
|
251
123
|
|
|
252
124
|
subscriptionCount(): number {
|
|
253
|
-
return this.#
|
|
125
|
+
return this.#signals.count();
|
|
254
126
|
}
|
|
255
127
|
|
|
256
128
|
on(event: TransportEvent, handler: (payload?: unknown) => void): void {
|
|
@@ -300,9 +172,7 @@ class DbusTransportImpl implements DbusTransport {
|
|
|
300
172
|
bus.connection.on('end', this.#onConnectionEnd);
|
|
301
173
|
|
|
302
174
|
// Re-issue every live match rule so a reconnect resubscribes transparently.
|
|
303
|
-
|
|
304
|
-
await bus.addMatch(rule);
|
|
305
|
-
}
|
|
175
|
+
await this.#signals.reissueRules(bus);
|
|
306
176
|
|
|
307
177
|
this.#bus = bus;
|
|
308
178
|
this.#state = 'connected';
|
|
@@ -319,58 +189,6 @@ class DbusTransportImpl implements DbusTransport {
|
|
|
319
189
|
}
|
|
320
190
|
}
|
|
321
191
|
|
|
322
|
-
#dispatchSignal(message: RawMessage): void {
|
|
323
|
-
if (message.type !== messageType.signal) {
|
|
324
|
-
return;
|
|
325
|
-
}
|
|
326
|
-
for (const record of this.#subscriptions.values()) {
|
|
327
|
-
if (!signalMatches(record.spec, message)) {
|
|
328
|
-
continue;
|
|
329
|
-
}
|
|
330
|
-
let body: DbusValue[];
|
|
331
|
-
try {
|
|
332
|
-
const signature = message.signature ?? '';
|
|
333
|
-
body = signature.length > 0 ? decodeBody(signature, message.body ?? []) : [];
|
|
334
|
-
} catch (error) {
|
|
335
|
-
this.#emitter.emit('error', error);
|
|
336
|
-
continue;
|
|
337
|
-
}
|
|
338
|
-
const event: SignalEvent = {
|
|
339
|
-
path: message.path ?? '',
|
|
340
|
-
interface: message.interface ?? '',
|
|
341
|
-
member: message.member ?? '',
|
|
342
|
-
sender: message.sender,
|
|
343
|
-
signature: message.signature ?? '',
|
|
344
|
-
body,
|
|
345
|
-
};
|
|
346
|
-
try {
|
|
347
|
-
record.listener(event);
|
|
348
|
-
} catch (error) {
|
|
349
|
-
this.#emitter.emit('error', error);
|
|
350
|
-
}
|
|
351
|
-
}
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
async #addMatchRule(rule: string): Promise<void> {
|
|
355
|
-
const current = this.#matchRuleRefcount.get(rule) ?? 0;
|
|
356
|
-
this.#matchRuleRefcount.set(rule, current + 1);
|
|
357
|
-
if (current === 0 && this.#state === 'connected' && this.#bus) {
|
|
358
|
-
await this.#bus.addMatch(rule);
|
|
359
|
-
}
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
async #removeMatchRule(rule: string): Promise<void> {
|
|
363
|
-
const current = this.#matchRuleRefcount.get(rule) ?? 0;
|
|
364
|
-
if (current <= 1) {
|
|
365
|
-
this.#matchRuleRefcount.delete(rule);
|
|
366
|
-
if (current === 1 && this.#state === 'connected' && this.#bus) {
|
|
367
|
-
await this.#bus.removeMatch(rule).catch(() => undefined);
|
|
368
|
-
}
|
|
369
|
-
} else {
|
|
370
|
-
this.#matchRuleRefcount.set(rule, current - 1);
|
|
371
|
-
}
|
|
372
|
-
}
|
|
373
|
-
|
|
374
192
|
#detachHandlers(bus: RawBus): void {
|
|
375
193
|
bus.connection.removeListener('message', this.#onMessage as (...args: unknown[]) => void);
|
|
376
194
|
bus.connection.removeListener('error', this.#onConnectionError);
|
|
@@ -385,13 +203,6 @@ class DbusTransportImpl implements DbusTransport {
|
|
|
385
203
|
bus.connection.on('error', () => undefined);
|
|
386
204
|
}
|
|
387
205
|
|
|
388
|
-
#rejectPending(cause: unknown): void {
|
|
389
|
-
for (const pending of this.#pending) {
|
|
390
|
-
pending.reject(cause);
|
|
391
|
-
}
|
|
392
|
-
this.#pending.clear();
|
|
393
|
-
}
|
|
394
|
-
|
|
395
206
|
#handleDrop(cause: unknown): void {
|
|
396
207
|
if (this.#closing) {
|
|
397
208
|
return;
|
|
@@ -404,7 +215,7 @@ class DbusTransportImpl implements DbusTransport {
|
|
|
404
215
|
this.#quiesce(this.#bus);
|
|
405
216
|
}
|
|
406
217
|
this.#bus = null;
|
|
407
|
-
this.#
|
|
218
|
+
this.#calls.rejectAll(cause);
|
|
408
219
|
this.#emitter.emit('disconnected', cause);
|
|
409
220
|
if (this.#reconnect.enabled) {
|
|
410
221
|
void this.#reconnectLoop();
|