@ceralive/modem-control 0.1.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.
Files changed (108) hide show
  1. package/package.json +31 -0
  2. package/src/backend/at-lease.test.ts +106 -0
  3. package/src/backend/at-lease.ts +158 -0
  4. package/src/backend/cell-info.test.ts +154 -0
  5. package/src/backend/cell-info.ts +160 -0
  6. package/src/backend/constants.ts +35 -0
  7. package/src/backend/device-classifier.test.ts +168 -0
  8. package/src/backend/device-classifier.ts +240 -0
  9. package/src/backend/enrichment.ts +96 -0
  10. package/src/backend/features.test.ts +158 -0
  11. package/src/backend/features.ts +179 -0
  12. package/src/backend/identity-ladder.test.ts +117 -0
  13. package/src/backend/identity-ladder.ts +221 -0
  14. package/src/backend/identity-registry.test.ts +89 -0
  15. package/src/backend/identity-registry.ts +151 -0
  16. package/src/backend/index.ts +221 -0
  17. package/src/backend/lifecycle-interlock.ts +38 -0
  18. package/src/backend/managed-objects.ts +108 -0
  19. package/src/backend/mapping.ts +160 -0
  20. package/src/backend/mm-backend.ts +191 -0
  21. package/src/backend/mm-mutations.ts +228 -0
  22. package/src/backend/modem-actor.test.ts +95 -0
  23. package/src/backend/modem-actor.ts +112 -0
  24. package/src/backend/nm-auto-apn.ts +161 -0
  25. package/src/backend/nm-gsm-fields.ts +122 -0
  26. package/src/backend/nmcli-nm-port.ts +228 -0
  27. package/src/backend/nmcli-runner.ts +52 -0
  28. package/src/backend/observer.ts +297 -0
  29. package/src/backend/power-contract.test.ts +40 -0
  30. package/src/backend/power-contract.ts +83 -0
  31. package/src/backend/recovery-attribution.test.ts +102 -0
  32. package/src/backend/recovery-attribution.ts +86 -0
  33. package/src/backend/recovery-budget.test.ts +64 -0
  34. package/src/backend/recovery-budget.ts +84 -0
  35. package/src/backend/recovery-ladder.test.ts +257 -0
  36. package/src/backend/recovery-ladder.ts +249 -0
  37. package/src/backend/router-ethernet.test.ts +71 -0
  38. package/src/backend/router-ethernet.ts +90 -0
  39. package/src/backend/row-store.ts +105 -0
  40. package/src/backend/signal-setup.ts +112 -0
  41. package/src/backend/sim-unlock.ts +193 -0
  42. package/src/backend/transition-preconditions.ts +149 -0
  43. package/src/backend/usage/accounting.test.ts +147 -0
  44. package/src/backend/usage/accounting.ts +123 -0
  45. package/src/backend/usage/billing-cycle.test.ts +62 -0
  46. package/src/backend/usage/billing-cycle.ts +45 -0
  47. package/src/backend/usage/boot-id.ts +18 -0
  48. package/src/backend/usage/index.ts +37 -0
  49. package/src/backend/usage/proc-net-dev.test.ts +56 -0
  50. package/src/backend/usage/proc-net-dev.ts +77 -0
  51. package/src/backend/usage/sampler.test.ts +219 -0
  52. package/src/backend/usage/sampler.ts +228 -0
  53. package/src/backend/usage/store.test.ts +148 -0
  54. package/src/backend/usage/store.ts +177 -0
  55. package/src/backend/usb-enumerator.test.ts +87 -0
  56. package/src/backend/usb-enumerator.ts +181 -0
  57. package/src/backend/usb-mode-transition.test.ts +323 -0
  58. package/src/backend/usb-mode-transition.ts +253 -0
  59. package/src/domain/brand.ts +29 -0
  60. package/src/domain/errors.ts +77 -0
  61. package/src/domain/guards.test.ts +218 -0
  62. package/src/domain/guards.ts +144 -0
  63. package/src/domain/identity.test.ts +83 -0
  64. package/src/domain/identity.ts +165 -0
  65. package/src/domain/index.ts +12 -0
  66. package/src/domain/policy.ts +137 -0
  67. package/src/domain/snapshot.test.ts +266 -0
  68. package/src/domain/snapshot.ts +120 -0
  69. package/src/domain/state.ts +189 -0
  70. package/src/index.test.ts +6 -0
  71. package/src/index.ts +15 -0
  72. package/src/ports/README.md +61 -0
  73. package/src/ports/forbidden-surface.test.ts +80 -0
  74. package/src/ports/index.ts +16 -0
  75. package/src/ports/modem-manager.ts +72 -0
  76. package/src/ports/network-manager.ts +87 -0
  77. package/src/ports/observation.ts +48 -0
  78. package/src/ports/ops.ts +60 -0
  79. package/src/ports/ops.type-test.ts +39 -0
  80. package/src/ports/receipts.test.ts +153 -0
  81. package/src/ports/receipts.ts +43 -0
  82. package/src/ports/reconcile.test.ts +152 -0
  83. package/src/ports/reconcile.ts +338 -0
  84. package/src/ports/router.ts +29 -0
  85. package/src/redact.test.ts +82 -0
  86. package/src/redact.ts +73 -0
  87. package/src/transport/README.md +65 -0
  88. package/src/transport/codec.test.ts +118 -0
  89. package/src/transport/codec.ts +240 -0
  90. package/src/transport/conformance-python.test.ts +152 -0
  91. package/src/transport/conformance-same-lib.test.ts +115 -0
  92. package/src/transport/dbus-native-lib.d.ts +19 -0
  93. package/src/transport/dbus-native.ts +85 -0
  94. package/src/transport/errors.ts +74 -0
  95. package/src/transport/index.ts +30 -0
  96. package/src/transport/no-library-leak.test.ts +50 -0
  97. package/src/transport/reliability.test.ts +173 -0
  98. package/src/transport/signature.ts +110 -0
  99. package/src/transport/test-support/fake-service.ts +143 -0
  100. package/src/transport/test-support/independent-producer.py +110 -0
  101. package/src/transport/test-support/private-bus.ts +66 -0
  102. package/src/transport/transport.ts +439 -0
  103. package/src/transport/types.ts +118 -0
  104. package/src/usb-mode/catalog-schema.test.ts +181 -0
  105. package/src/usb-mode/catalog-schema.ts +113 -0
  106. package/src/usb-mode/catalog.ts +60 -0
  107. package/src/usb-mode/certified-catalog.json +67 -0
  108. package/src/usb-mode/index.ts +27 -0
@@ -0,0 +1,110 @@
1
+ #!/usr/bin/env python3
2
+ """Independent D-Bus producer for transport conformance half (b).
3
+
4
+ A different implementation on the wire than the JavaScript library under test: this uses
5
+ the `dbus-python` bindings (`python3-dbus`) plus a GLib main loop. If our TypeScript codec
6
+ agrees with THIS producer as well as with the same-library fake, the codec is correct, not
7
+ merely self-consistent.
8
+
9
+ It exercises the shapes the plan calls out: the real ModemManager `GetManagedObjects`
10
+ shape `a{oa{sa{sv}}}`, 64-bit `x`/`t` values above 2**53, variants, and a
11
+ `PropertiesChanged` signal that carries an invalidated-properties array (not just changed
12
+ values).
13
+
14
+ Connects to the bus named by `DBUS_SESSION_BUS_ADDRESS` (set by `dbus-run-session`),
15
+ claims a well-known name, prints `READY` on stdout once serving, then runs the main loop.
16
+ """
17
+
18
+ import sys
19
+
20
+ import dbus
21
+ import dbus.mainloop.glib
22
+ import dbus.service
23
+ from gi.repository import GLib
24
+
25
+ BUS_NAME = "tv.ceralive.ModemStackPy"
26
+ OBJECT_PATH = "/tv/ceralive/pyfake"
27
+ IFACE = "tv.ceralive.ModemStackPy.Conformance"
28
+ PROPERTIES_IFACE = "org.freedesktop.DBus.Properties"
29
+
30
+ INT64_MAX = 2**63 - 1
31
+ UINT64_MAX = 2**64 - 1
32
+
33
+
34
+ class ConformanceProducer(dbus.service.Object):
35
+ @dbus.service.method(IFACE, in_signature="", out_signature="a{oa{sa{sv}}}")
36
+ def GetManagedObjects(self):
37
+ return dbus.Dictionary(
38
+ {
39
+ dbus.ObjectPath("/org/freedesktop/ModemManager1/Modem/0"): dbus.Dictionary(
40
+ {
41
+ "org.freedesktop.ModemManager1.Modem": dbus.Dictionary(
42
+ {
43
+ "SupportedCapabilities": dbus.UInt64(UINT64_MAX, variant_level=1),
44
+ "MaxBearers": dbus.Int64(-INT64_MAX, variant_level=1),
45
+ "SignalQuality": dbus.UInt32(87, variant_level=1),
46
+ "DeviceIdentifier": dbus.String("py-device-0", variant_level=1),
47
+ },
48
+ signature="sv",
49
+ ),
50
+ },
51
+ signature="sa{sv}",
52
+ ),
53
+ },
54
+ signature="oa{sa{sv}}",
55
+ )
56
+
57
+ @dbus.service.method(IFACE, in_signature="", out_signature="x")
58
+ def GetInt64(self):
59
+ return dbus.Int64(INT64_MAX)
60
+
61
+ @dbus.service.method(IFACE, in_signature="", out_signature="t")
62
+ def GetUint64(self):
63
+ return dbus.UInt64(UINT64_MAX)
64
+
65
+ @dbus.service.method(IFACE, in_signature="", out_signature="v")
66
+ def GetVariant(self):
67
+ return dbus.UInt64(UINT64_MAX, variant_level=1)
68
+
69
+ @dbus.service.method(IFACE, in_signature="x", out_signature="x")
70
+ def EchoInt64(self, value):
71
+ return dbus.Int64(value)
72
+
73
+ @dbus.service.method(IFACE, in_signature="t", out_signature="t")
74
+ def EchoUint64(self, value):
75
+ return dbus.UInt64(value)
76
+
77
+ @dbus.service.method(IFACE, in_signature="t", out_signature="s")
78
+ def DescribeUint64(self, value):
79
+ return dbus.String(str(int(value)))
80
+
81
+ @dbus.service.method(IFACE, in_signature="", out_signature="")
82
+ def TriggerPropertiesChanged(self):
83
+ changed = dbus.Dictionary(
84
+ {
85
+ "AccessTechnologies": dbus.UInt64(UINT64_MAX, variant_level=1),
86
+ "SignalQuality": dbus.UInt32(42, variant_level=1),
87
+ },
88
+ signature="sv",
89
+ )
90
+ invalidated = dbus.Array(["OperatorName", "OperatorCode"], signature="s")
91
+ self.PropertiesChanged(IFACE, changed, invalidated)
92
+
93
+ @dbus.service.signal(PROPERTIES_IFACE, signature="sa{sv}as")
94
+ def PropertiesChanged(self, interface_name, changed_properties, invalidated_properties):
95
+ pass
96
+
97
+
98
+ def main():
99
+ dbus.mainloop.glib.DBusGMainLoop(set_as_default=True)
100
+ bus = dbus.SessionBus()
101
+ name = dbus.service.BusName(BUS_NAME, bus)
102
+ ConformanceProducer(bus, OBJECT_PATH)
103
+ # Signal readiness only after the name is owned and the object is exported.
104
+ sys.stdout.write("READY\n")
105
+ sys.stdout.flush()
106
+ GLib.MainLoop().run()
107
+
108
+
109
+ if __name__ == "__main__":
110
+ main()
@@ -0,0 +1,66 @@
1
+ // A dedicated private `dbus-daemon` at a FIXED socket path, for the reconnect test.
2
+ //
3
+ // The outer `dbus-run-session` bus is shared by the whole `bun test` run, so the
4
+ // destructive kill/restart cannot happen there. This helper owns a throwaway daemon we
5
+ // can kill and respawn at the same socket path, so the transport reconnects to "the same
6
+ // bus" exactly as it would after a real bus restart on a device.
7
+
8
+ import { existsSync, mkdtempSync, rmSync } from 'node:fs';
9
+ import { tmpdir } from 'node:os';
10
+ import { join } from 'node:path';
11
+
12
+ const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
13
+
14
+ export class PrivateBus {
15
+ readonly socket: string;
16
+ readonly address: string;
17
+ readonly #dir: string;
18
+ #proc: ReturnType<typeof Bun.spawn> | null = null;
19
+
20
+ constructor() {
21
+ this.#dir = mkdtempSync(join(tmpdir(), 'ceralive-dbus-'));
22
+ this.socket = join(this.#dir, 'bus');
23
+ this.address = `unix:path=${this.socket}`;
24
+ }
25
+
26
+ async start(): Promise<void> {
27
+ // A SIGKILL leaves the socket file behind; dbus-daemon refuses to bind over it.
28
+ if (existsSync(this.socket)) {
29
+ rmSync(this.socket, { force: true });
30
+ }
31
+ this.#proc = Bun.spawn(
32
+ ['dbus-daemon', '--session', `--address=${this.address}`, '--nofork', '--nopidfile'],
33
+ { stdout: 'ignore', stderr: 'ignore' },
34
+ );
35
+ await this.#waitForSocket();
36
+ }
37
+
38
+ kill(): void {
39
+ this.#proc?.kill('SIGKILL');
40
+ this.#proc = null;
41
+ }
42
+
43
+ async restart(): Promise<void> {
44
+ this.kill();
45
+ await sleep(50);
46
+ await this.start();
47
+ }
48
+
49
+ async stop(): Promise<void> {
50
+ this.kill();
51
+ await sleep(20);
52
+ rmSync(this.#dir, { recursive: true, force: true });
53
+ }
54
+
55
+ async #waitForSocket(): Promise<void> {
56
+ for (let attempt = 0; attempt < 300; attempt += 1) {
57
+ if (existsSync(this.socket)) {
58
+ // Socket exists → daemon is listening; a short grace covers the accept race.
59
+ await sleep(30);
60
+ return;
61
+ }
62
+ await sleep(10);
63
+ }
64
+ throw new Error(`private dbus-daemon socket ${this.socket} never appeared`);
65
+ }
66
+ }
@@ -0,0 +1,439 @@
1
+ // The D-Bus transport seam implementation.
2
+ //
3
+ // Wraps `@httptoolkit/dbus-native` behind the `DbusTransport` interface: method calls,
4
+ // signal subscriptions, and an automatic reconnect loop that re-issues every match rule
5
+ // after a bus restart. A single persistent `message` listener fans out to the live
6
+ // subscription registry, so subscribing/unsubscribing never grows the connection's
7
+ // listener count — the 100-cycle leak check depends on this.
8
+
9
+ import { EventEmitter } from 'node:events';
10
+ import { decodeBody, encodeBody } from './codec';
11
+ import {
12
+ type CreateClientOptions,
13
+ createClient,
14
+ messageType,
15
+ type RawBus,
16
+ type RawMessage,
17
+ type ReplyContext,
18
+ } from './dbus-native';
19
+ import { DisconnectedError, TransportError } from './errors';
20
+ import type {
21
+ DbusTransport,
22
+ DbusTransportOptions,
23
+ DbusValue,
24
+ MethodCall,
25
+ MethodReply,
26
+ SignalEvent,
27
+ SignalListener,
28
+ SignalSpec,
29
+ Subscription,
30
+ TransportEvent,
31
+ } from './types';
32
+
33
+ type State = 'idle' | 'connecting' | 'connected' | 'disconnected' | 'reconnecting' | 'closed';
34
+
35
+ interface ResolvedReconnect {
36
+ readonly enabled: boolean;
37
+ readonly initialDelayMs: number;
38
+ readonly maxDelayMs: number;
39
+ readonly maxAttempts: number;
40
+ }
41
+
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
+ // Bound a single connect/auth attempt so a stalled handshake cannot freeze the reconnect
56
+ // loop. A local unix-socket D-Bus connect completes in milliseconds; 2s is ample headroom
57
+ // while keeping reconnect responsive after a bus restart.
58
+ const CONNECT_TIMEOUT_MS = 2_000;
59
+ const DEFAULT_RECONNECT: ResolvedReconnect = {
60
+ enabled: true,
61
+ initialDelayMs: 50,
62
+ maxDelayMs: 2_000,
63
+ maxAttempts: 0,
64
+ };
65
+
66
+ const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
67
+
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
+ class DbusTransportImpl implements DbusTransport {
93
+ readonly #options: DbusTransportOptions;
94
+ readonly #reconnect: ResolvedReconnect;
95
+ readonly #callTimeoutMs: number;
96
+ readonly #emitter = new EventEmitter();
97
+ readonly #subscriptions = new Map<number, SubscriptionRecord>();
98
+ readonly #matchRuleRefcount = new Map<string, number>();
99
+ readonly #pending = new Set<PendingCall>();
100
+
101
+ #bus: RawBus | null = null;
102
+ #state: State = 'idle';
103
+ #closing = false;
104
+ #nextSubId = 1;
105
+
106
+ // Bound once so the same references can be detached from a dead connection.
107
+ readonly #onMessage = (message: RawMessage): void => this.#dispatchSignal(message);
108
+ readonly #onConnectionError = (cause: unknown): void =>
109
+ this.#handleDrop(cause instanceof Error ? cause : new DisconnectedError(String(cause)));
110
+ readonly #onConnectionEnd = (): void =>
111
+ this.#handleDrop(new DisconnectedError('bus connection ended'));
112
+
113
+ constructor(options: DbusTransportOptions) {
114
+ this.#options = options;
115
+ this.#callTimeoutMs = options.callTimeoutMs ?? DEFAULT_CALL_TIMEOUT_MS;
116
+ this.#reconnect = {
117
+ enabled: options.reconnect?.enabled ?? DEFAULT_RECONNECT.enabled,
118
+ initialDelayMs: options.reconnect?.initialDelayMs ?? DEFAULT_RECONNECT.initialDelayMs,
119
+ maxDelayMs: options.reconnect?.maxDelayMs ?? DEFAULT_RECONNECT.maxDelayMs,
120
+ maxAttempts: options.reconnect?.maxAttempts ?? DEFAULT_RECONNECT.maxAttempts,
121
+ };
122
+ // Avoid MaxListeners warnings when many consumers observe transport events.
123
+ this.#emitter.setMaxListeners(0);
124
+ }
125
+
126
+ async connect(): Promise<void> {
127
+ if (this.#state === 'connected') {
128
+ return;
129
+ }
130
+ if (this.#closing) {
131
+ throw new TransportError('Transport is closed');
132
+ }
133
+ this.#state = 'connecting';
134
+ await this.#establish();
135
+ this.#emitter.emit('connected');
136
+ }
137
+
138
+ isConnected(): boolean {
139
+ return this.#state === 'connected';
140
+ }
141
+
142
+ async disconnect(): Promise<void> {
143
+ this.#closing = true;
144
+ this.#state = 'closed';
145
+ const bus = this.#bus;
146
+ this.#bus = null;
147
+ this.#rejectPending(new DisconnectedError('transport closed'));
148
+ if (bus) {
149
+ this.#quiesce(bus);
150
+ await bus.disconnect().catch(() => undefined);
151
+ }
152
+ }
153
+
154
+ async callMethod(call: MethodCall): Promise<MethodReply> {
155
+ const bus = this.#bus;
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
+ });
231
+ }
232
+
233
+ async subscribeSignal(spec: SignalSpec, listener: SignalListener): Promise<Subscription> {
234
+ const rule = buildMatchRule(spec);
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
+ };
250
+ }
251
+
252
+ subscriptionCount(): number {
253
+ return this.#subscriptions.size;
254
+ }
255
+
256
+ on(event: TransportEvent, handler: (payload?: unknown) => void): void {
257
+ this.#emitter.on(event, handler);
258
+ }
259
+
260
+ off(event: TransportEvent, handler: (payload?: unknown) => void): void {
261
+ this.#emitter.off(event, handler);
262
+ }
263
+
264
+ // ── internals ──────────────────────────────────────────────────────────────────
265
+
266
+ async #establish(): Promise<void> {
267
+ const options: CreateClientOptions = { ReturnLongjs: true };
268
+ if (this.#options.socket !== undefined) {
269
+ options.socket = this.#options.socket;
270
+ } else if (this.#options.busAddress !== undefined) {
271
+ options.busAddress = this.#options.busAddress;
272
+ }
273
+
274
+ const bus = createClient(options);
275
+ try {
276
+ await new Promise<void>((resolve, reject) => {
277
+ const onConnect = (): void => {
278
+ cleanup();
279
+ resolve();
280
+ };
281
+ const onError = (error: unknown): void => {
282
+ cleanup();
283
+ reject(error instanceof Error ? error : new TransportError(String(error)));
284
+ };
285
+ const timer = setTimeout(() => {
286
+ cleanup();
287
+ reject(new TransportError(`bus connect timed out after ${CONNECT_TIMEOUT_MS}ms`));
288
+ }, CONNECT_TIMEOUT_MS);
289
+ const cleanup = (): void => {
290
+ clearTimeout(timer);
291
+ bus.connection.removeListener('connect', onConnect);
292
+ bus.connection.removeListener('error', onError);
293
+ };
294
+ bus.connection.once('connect', onConnect);
295
+ bus.connection.once('error', onError);
296
+ });
297
+
298
+ bus.connection.on('message', this.#onMessage as (...args: unknown[]) => void);
299
+ bus.connection.on('error', this.#onConnectionError);
300
+ bus.connection.on('end', this.#onConnectionEnd);
301
+
302
+ // Re-issue every live match rule so a reconnect resubscribes transparently.
303
+ for (const rule of this.#matchRuleRefcount.keys()) {
304
+ await bus.addMatch(rule);
305
+ }
306
+
307
+ this.#bus = bus;
308
+ this.#state = 'connected';
309
+ } catch (error) {
310
+ this.#detachHandlers(bus);
311
+ bus.connection.removeAllListeners();
312
+ bus.connection.on('error', () => undefined);
313
+ try {
314
+ bus.connection.end();
315
+ } catch {
316
+ // The half-open connection is already dead; nothing to close.
317
+ }
318
+ throw error;
319
+ }
320
+ }
321
+
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
+ #detachHandlers(bus: RawBus): void {
375
+ bus.connection.removeListener('message', this.#onMessage as (...args: unknown[]) => void);
376
+ bus.connection.removeListener('error', this.#onConnectionError);
377
+ bus.connection.removeListener('end', this.#onConnectionEnd);
378
+ }
379
+
380
+ // Detach our handlers from a dead connection, then swallow any late socket error it
381
+ // still emits — without a listener, Node re-throws an EventEmitter 'error' and crashes
382
+ // the process mid-reconnect.
383
+ #quiesce(bus: RawBus): void {
384
+ this.#detachHandlers(bus);
385
+ bus.connection.on('error', () => undefined);
386
+ }
387
+
388
+ #rejectPending(cause: unknown): void {
389
+ for (const pending of this.#pending) {
390
+ pending.reject(cause);
391
+ }
392
+ this.#pending.clear();
393
+ }
394
+
395
+ #handleDrop(cause: unknown): void {
396
+ if (this.#closing) {
397
+ return;
398
+ }
399
+ if (this.#state === 'disconnected' || this.#state === 'reconnecting') {
400
+ return;
401
+ }
402
+ this.#state = 'disconnected';
403
+ if (this.#bus) {
404
+ this.#quiesce(this.#bus);
405
+ }
406
+ this.#bus = null;
407
+ this.#rejectPending(cause);
408
+ this.#emitter.emit('disconnected', cause);
409
+ if (this.#reconnect.enabled) {
410
+ void this.#reconnectLoop();
411
+ }
412
+ }
413
+
414
+ async #reconnectLoop(): Promise<void> {
415
+ this.#state = 'reconnecting';
416
+ let delay = this.#reconnect.initialDelayMs;
417
+ let attempt = 0;
418
+ while (!this.#closing) {
419
+ attempt += 1;
420
+ try {
421
+ await this.#establish();
422
+ this.#emitter.emit('reconnected');
423
+ return;
424
+ } catch (error) {
425
+ if (this.#reconnect.maxAttempts > 0 && attempt >= this.#reconnect.maxAttempts) {
426
+ this.#state = 'disconnected';
427
+ this.#emitter.emit('error', error);
428
+ return;
429
+ }
430
+ await sleep(delay);
431
+ delay = Math.min(delay * 2, this.#reconnect.maxDelayMs);
432
+ }
433
+ }
434
+ }
435
+ }
436
+
437
+ export function createDbusTransport(options: DbusTransportOptions = {}): DbusTransport {
438
+ return new DbusTransportImpl(options);
439
+ }