@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
|
@@ -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
|
};
|