@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.
@@ -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
- const productionModules = ['transport.ts', 'codec.ts', 'signature.ts', 'errors.ts', 'types.ts'];
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
- expect(source).not.toContain(LIBRARY_IMPORT);
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) => new Promise((resolve) => setTimeout(() => resolve('pong'), Number(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
  };