@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.
- package/package.json +31 -0
- package/src/backend/at-lease.test.ts +106 -0
- package/src/backend/at-lease.ts +158 -0
- package/src/backend/cell-info.test.ts +154 -0
- package/src/backend/cell-info.ts +160 -0
- package/src/backend/constants.ts +35 -0
- package/src/backend/device-classifier.test.ts +168 -0
- package/src/backend/device-classifier.ts +240 -0
- package/src/backend/enrichment.ts +96 -0
- package/src/backend/features.test.ts +158 -0
- package/src/backend/features.ts +179 -0
- package/src/backend/identity-ladder.test.ts +117 -0
- package/src/backend/identity-ladder.ts +221 -0
- package/src/backend/identity-registry.test.ts +89 -0
- package/src/backend/identity-registry.ts +151 -0
- package/src/backend/index.ts +221 -0
- package/src/backend/lifecycle-interlock.ts +38 -0
- package/src/backend/managed-objects.ts +108 -0
- package/src/backend/mapping.ts +160 -0
- package/src/backend/mm-backend.ts +191 -0
- package/src/backend/mm-mutations.ts +228 -0
- package/src/backend/modem-actor.test.ts +95 -0
- package/src/backend/modem-actor.ts +112 -0
- package/src/backend/nm-auto-apn.ts +161 -0
- package/src/backend/nm-gsm-fields.ts +122 -0
- package/src/backend/nmcli-nm-port.ts +228 -0
- package/src/backend/nmcli-runner.ts +52 -0
- package/src/backend/observer.ts +297 -0
- package/src/backend/power-contract.test.ts +40 -0
- package/src/backend/power-contract.ts +83 -0
- package/src/backend/recovery-attribution.test.ts +102 -0
- package/src/backend/recovery-attribution.ts +86 -0
- package/src/backend/recovery-budget.test.ts +64 -0
- package/src/backend/recovery-budget.ts +84 -0
- package/src/backend/recovery-ladder.test.ts +257 -0
- package/src/backend/recovery-ladder.ts +249 -0
- package/src/backend/router-ethernet.test.ts +71 -0
- package/src/backend/router-ethernet.ts +90 -0
- package/src/backend/row-store.ts +105 -0
- package/src/backend/signal-setup.ts +112 -0
- package/src/backend/sim-unlock.ts +193 -0
- package/src/backend/transition-preconditions.ts +149 -0
- package/src/backend/usage/accounting.test.ts +147 -0
- package/src/backend/usage/accounting.ts +123 -0
- package/src/backend/usage/billing-cycle.test.ts +62 -0
- package/src/backend/usage/billing-cycle.ts +45 -0
- package/src/backend/usage/boot-id.ts +18 -0
- package/src/backend/usage/index.ts +37 -0
- package/src/backend/usage/proc-net-dev.test.ts +56 -0
- package/src/backend/usage/proc-net-dev.ts +77 -0
- package/src/backend/usage/sampler.test.ts +219 -0
- package/src/backend/usage/sampler.ts +228 -0
- package/src/backend/usage/store.test.ts +148 -0
- package/src/backend/usage/store.ts +177 -0
- package/src/backend/usb-enumerator.test.ts +87 -0
- package/src/backend/usb-enumerator.ts +181 -0
- package/src/backend/usb-mode-transition.test.ts +323 -0
- package/src/backend/usb-mode-transition.ts +253 -0
- package/src/domain/brand.ts +29 -0
- package/src/domain/errors.ts +77 -0
- package/src/domain/guards.test.ts +218 -0
- package/src/domain/guards.ts +144 -0
- package/src/domain/identity.test.ts +83 -0
- package/src/domain/identity.ts +165 -0
- package/src/domain/index.ts +12 -0
- package/src/domain/policy.ts +137 -0
- package/src/domain/snapshot.test.ts +266 -0
- package/src/domain/snapshot.ts +120 -0
- package/src/domain/state.ts +189 -0
- package/src/index.test.ts +6 -0
- package/src/index.ts +15 -0
- package/src/ports/README.md +61 -0
- package/src/ports/forbidden-surface.test.ts +80 -0
- package/src/ports/index.ts +16 -0
- package/src/ports/modem-manager.ts +72 -0
- package/src/ports/network-manager.ts +87 -0
- package/src/ports/observation.ts +48 -0
- package/src/ports/ops.ts +60 -0
- package/src/ports/ops.type-test.ts +39 -0
- package/src/ports/receipts.test.ts +153 -0
- package/src/ports/receipts.ts +43 -0
- package/src/ports/reconcile.test.ts +152 -0
- package/src/ports/reconcile.ts +338 -0
- package/src/ports/router.ts +29 -0
- package/src/redact.test.ts +82 -0
- package/src/redact.ts +73 -0
- package/src/transport/README.md +65 -0
- package/src/transport/codec.test.ts +118 -0
- package/src/transport/codec.ts +240 -0
- package/src/transport/conformance-python.test.ts +152 -0
- package/src/transport/conformance-same-lib.test.ts +115 -0
- package/src/transport/dbus-native-lib.d.ts +19 -0
- package/src/transport/dbus-native.ts +85 -0
- package/src/transport/errors.ts +74 -0
- package/src/transport/index.ts +30 -0
- package/src/transport/no-library-leak.test.ts +50 -0
- package/src/transport/reliability.test.ts +173 -0
- package/src/transport/signature.ts +110 -0
- package/src/transport/test-support/fake-service.ts +143 -0
- package/src/transport/test-support/independent-producer.py +110 -0
- package/src/transport/test-support/private-bus.ts +66 -0
- package/src/transport/transport.ts +439 -0
- package/src/transport/types.ts +118 -0
- package/src/usb-mode/catalog-schema.test.ts +181 -0
- package/src/usb-mode/catalog-schema.ts +113 -0
- package/src/usb-mode/catalog.ts +60 -0
- package/src/usb-mode/certified-catalog.json +67 -0
- 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
|
+
}
|