@ceralive/modem-control 1.2.0 → 1.3.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 (87) hide show
  1. package/README.md +84 -0
  2. package/dist/backend/cell-info.d.ts +2 -0
  3. package/dist/backend/cell-info.js +18 -2
  4. package/dist/backend/device-classifier.d.ts +56 -0
  5. package/dist/backend/device-classifier.js +88 -0
  6. package/dist/backend/mm-mutations/bands.d.ts +4 -0
  7. package/dist/backend/mm-mutations/bands.js +49 -0
  8. package/dist/backend/mm-mutations/context.d.ts +10 -0
  9. package/dist/backend/mm-mutations/context.js +3 -0
  10. package/dist/backend/mm-mutations/inhibit.d.ts +4 -0
  11. package/dist/backend/mm-mutations/inhibit.js +19 -0
  12. package/dist/backend/mm-mutations/modes.d.ts +5 -0
  13. package/dist/backend/mm-mutations/modes.js +44 -0
  14. package/dist/backend/mm-mutations/scan.d.ts +3 -0
  15. package/dist/backend/mm-mutations/scan.js +46 -0
  16. package/dist/backend/mm-mutations/sim.d.ts +5 -0
  17. package/dist/backend/mm-mutations/sim.js +49 -0
  18. package/dist/backend/mm-mutations.d.ts +0 -16
  19. package/dist/backend/mm-mutations.js +27 -234
  20. package/dist/backend/observer/bus-lifecycle.d.ts +7 -0
  21. package/dist/backend/observer/bus-lifecycle.js +12 -0
  22. package/dist/backend/observer/epoch-reconcile.d.ts +4 -0
  23. package/dist/backend/observer/epoch-reconcile.js +28 -0
  24. package/dist/backend/observer/signal-routing.d.ts +11 -0
  25. package/dist/backend/observer/signal-routing.js +11 -0
  26. package/dist/backend/observer.d.ts +1 -1
  27. package/dist/backend/observer.js +18 -35
  28. package/dist/backend/router-ethernet.d.ts +7 -0
  29. package/dist/backend/router-ethernet.js +70 -18
  30. package/dist/backend/signal-setup.d.ts +21 -2
  31. package/dist/backend/signal-setup.js +24 -2
  32. package/dist/backend/usage/accounting.d.ts +7 -0
  33. package/dist/backend/usage/accounting.js +8 -2
  34. package/dist/backend/usage/persistence.d.ts +4 -0
  35. package/dist/backend/usage/persistence.js +59 -0
  36. package/dist/backend/usage/policy.d.ts +24 -0
  37. package/dist/backend/usage/policy.js +38 -0
  38. package/dist/backend/usage/sampler.d.ts +7 -1
  39. package/dist/backend/usage/sampler.js +33 -119
  40. package/dist/backend/usage/sampling.d.ts +38 -0
  41. package/dist/backend/usage/sampling.js +72 -0
  42. package/dist/backend/usb-mode-transition/admission.d.ts +5 -0
  43. package/dist/backend/usb-mode-transition/admission.js +12 -0
  44. package/dist/backend/usb-mode-transition/at.d.ts +13 -0
  45. package/dist/backend/usb-mode-transition/at.js +15 -0
  46. package/dist/backend/usb-mode-transition/outcome.d.ts +4 -0
  47. package/dist/backend/usb-mode-transition/outcome.js +17 -0
  48. package/dist/backend/usb-mode-transition/reenumeration.d.ts +8 -0
  49. package/dist/backend/usb-mode-transition/reenumeration.js +37 -0
  50. package/dist/backend/usb-mode-transition.d.ts +2 -2
  51. package/dist/backend/usb-mode-transition.js +25 -71
  52. package/dist/domain/mm-enums.d.ts +37 -0
  53. package/dist/domain/mm-enums.js +34 -0
  54. package/dist/fcc/coverage.d.ts +7 -0
  55. package/dist/fcc/coverage.js +7 -0
  56. package/dist/hardware/router-parsers.js +3 -32
  57. package/dist/json-boundary.d.ts +4 -0
  58. package/dist/json-boundary.js +19 -0
  59. package/dist/observations/model.d.ts +45 -0
  60. package/dist/observations/raw.d.ts +12 -0
  61. package/dist/observations/raw.js +31 -0
  62. package/dist/observations/sources/hilink.js +7 -1
  63. package/dist/observations/sources/modemmanager.d.ts +25 -0
  64. package/dist/observations/sources/modemmanager.js +123 -15
  65. package/dist/observations/sources/router-shared.d.ts +35 -1
  66. package/dist/observations/sources/router-shared.js +39 -0
  67. package/dist/observations/sources/ufi.js +7 -1
  68. package/dist/observations/sources/zte.js +7 -1
  69. package/dist/providers/modem-manager/provider.d.ts +10 -0
  70. package/dist/providers/modem-manager/provider.js +3 -0
  71. package/dist/providers/modem-manager/runtime-composition-operation.js +2 -0
  72. package/dist/providers/modem-manager/snapshot.js +6 -0
  73. package/dist/providers/network-manager/adapter.js +15 -136
  74. package/dist/providers/network-manager/divergence.d.ts +4 -0
  75. package/dist/providers/network-manager/divergence.js +7 -0
  76. package/dist/providers/network-manager/observe-fold.d.ts +9 -0
  77. package/dist/providers/network-manager/observe-fold.js +109 -0
  78. package/dist/providers/network-manager/projection.d.ts +5 -0
  79. package/dist/providers/network-manager/projection.js +14 -0
  80. package/dist/providers/network-manager/state.d.ts +10 -0
  81. package/dist/providers/network-manager/state.js +16 -0
  82. package/dist/providers/ufi-himi/session.js +2 -16
  83. package/dist/providers/zte-goform/session.js +2 -16
  84. package/dist/safety/flock-resource-ownership.js +73 -13
  85. package/dist/transport/transport.d.ts +20 -1
  86. package/dist/transport/transport.js +133 -16
  87. package/package.json +1 -1
@@ -0,0 +1,109 @@
1
+ import { freshObservation, observedState, unavailableObservation } from '../../observations/index.js';
2
+ import { targetIfname } from './state.js';
3
+ import { boundBearer, unboundBearer } from './types.js';
4
+ const SOURCE = 'networkmanager';
5
+ const TRANSITIONAL_STATES = new Set([
6
+ 'prepare',
7
+ 'config',
8
+ 'need-auth',
9
+ 'ip-config',
10
+ 'ip-check',
11
+ 'secondaries',
12
+ 'deactivating',
13
+ ]);
14
+ export function foldObservation(slotsByConnection, observedGeneration, input) {
15
+ const generation = input.context.generation;
16
+ if (observedGeneration !== null && generation < observedGeneration) {
17
+ return {
18
+ generation: observedGeneration,
19
+ result: {
20
+ kind: 'refused',
21
+ reason: 'superseded-generation',
22
+ currentGeneration: observedGeneration,
23
+ },
24
+ };
25
+ }
26
+ const devices = new Map(input.devices.map((device) => [device.ifname, device]));
27
+ const outcomes = [];
28
+ const losses = [];
29
+ for (const [id, slots] of slotsByConnection) {
30
+ const ifname = targetIfname(slots);
31
+ if (ifname === undefined) {
32
+ continue;
33
+ }
34
+ const device = devices.get(ifname);
35
+ slots.observed = observedState(device === undefined
36
+ ? unavailableObservation(SOURCE, input.context, 'device-absent')
37
+ : freshObservation(SOURCE, input.context, observedBearer(device)));
38
+ const outcome = classifyApplied(id, slots, ifname, device, input.context.observedAt);
39
+ outcomes.push({ connectionId: id, outcome });
40
+ if (outcome.status === 'lost') {
41
+ losses.push(outcome.loss);
42
+ }
43
+ }
44
+ return {
45
+ generation,
46
+ result: {
47
+ kind: 'accepted',
48
+ generation,
49
+ observedAt: input.context.observedAt,
50
+ outcomes,
51
+ losses,
52
+ },
53
+ };
54
+ }
55
+ function classifyApplied(id, slots, ifname, device, observedAt) {
56
+ const applied = slots.applied;
57
+ if (applied === null) {
58
+ return { status: 'unapplied' };
59
+ }
60
+ const lose = (reason) => {
61
+ slots.applied = null;
62
+ return {
63
+ status: 'lost',
64
+ loss: {
65
+ connectionId: id,
66
+ deviceIfname: ifname,
67
+ reason,
68
+ lostAt: observedAt,
69
+ generation: applied.generation,
70
+ previous: applied,
71
+ },
72
+ };
73
+ };
74
+ if (device === undefined) {
75
+ return lose('interface-absent');
76
+ }
77
+ if (device.state === 'failed') {
78
+ return lose('activation-failed');
79
+ }
80
+ if (applied.configuration.kind === 'unbound') {
81
+ return device.activeConnection === undefined
82
+ ? { status: 'retained', applied }
83
+ : lose('connection-replaced');
84
+ }
85
+ if (device.activeConnection === undefined) {
86
+ return lose('interface-detached');
87
+ }
88
+ if (device.activeConnection.connectionId !== id) {
89
+ return lose('connection-replaced');
90
+ }
91
+ if (device.state === 'activated') {
92
+ return { status: 'retained', applied };
93
+ }
94
+ return TRANSITIONAL_STATES.has(device.state)
95
+ ? { status: 'pending', applied, deviceState: device.state }
96
+ : lose('interface-detached');
97
+ }
98
+ function observedBearer(device) {
99
+ const active = device.activeConnection;
100
+ return active === undefined
101
+ ? unboundBearer(device.ifname)
102
+ : boundBearer({
103
+ connectionId: active.connectionId,
104
+ deviceIfname: device.ifname,
105
+ apn: active.apn,
106
+ autoConfig: active.autoConfig,
107
+ homeOnly: active.homeOnly,
108
+ });
109
+ }
@@ -0,0 +1,5 @@
1
+ import type { ModemStateView, NormalizationContext } from '../../observations/index.js';
2
+ import type { ConnectionId } from '../../ports/index.js';
3
+ import type { ConnectionSlots } from './state.js';
4
+ import type { NmBearerState } from './types.js';
5
+ export declare function projectStateView(slotsByConnection: ReadonlyMap<ConnectionId, ConnectionSlots>, id: ConnectionId, context: NormalizationContext): ModemStateView<NmBearerState, NmBearerState, NmBearerState> | null;
@@ -0,0 +1,14 @@
1
+ import { observedState, unavailableObservation } from '../../observations/index.js';
2
+ const SOURCE = 'networkmanager';
3
+ export function projectStateView(slotsByConnection, id, context) {
4
+ const slots = slotsByConnection.get(id);
5
+ if (slots === undefined) {
6
+ return null;
7
+ }
8
+ return {
9
+ desired: slots.desired,
10
+ applied: slots.applied,
11
+ observed: slots.observed ??
12
+ observedState(unavailableObservation(SOURCE, context, 'provider-unavailable')),
13
+ };
14
+ }
@@ -0,0 +1,10 @@
1
+ import type { AppliedConfiguration, DesiredProfile, ObservedState } from '../../observations/index.js';
2
+ import type { ConnectionId, DeviceIfname } from '../../ports/index.js';
3
+ import type { NmBearerState } from './types.js';
4
+ export interface ConnectionSlots {
5
+ desired: DesiredProfile<NmBearerState> | null;
6
+ applied: AppliedConfiguration<NmBearerState> | null;
7
+ observed: ObservedState<NmBearerState> | null;
8
+ }
9
+ export declare function slotsFor(slotsByConnection: Map<ConnectionId, ConnectionSlots>, id: ConnectionId): ConnectionSlots;
10
+ export declare function targetIfname(slots: ConnectionSlots): DeviceIfname | undefined;
@@ -0,0 +1,16 @@
1
+ export function slotsFor(slotsByConnection, id) {
2
+ const existing = slotsByConnection.get(id);
3
+ if (existing !== undefined) {
4
+ return existing;
5
+ }
6
+ const created = { desired: null, applied: null, observed: null };
7
+ slotsByConnection.set(id, created);
8
+ return created;
9
+ }
10
+ export function targetIfname(slots) {
11
+ const state = slots.applied?.configuration ?? slots.desired?.profile;
12
+ if (state === undefined) {
13
+ return undefined;
14
+ }
15
+ return state.kind === 'bound' ? state.binding.deviceIfname : state.deviceIfname;
16
+ }
@@ -1,4 +1,5 @@
1
1
  import { z } from 'zod';
2
+ import { parseJsonWith } from '../../json-boundary.js';
2
3
  import { UFI_API_PATH, } from './transport.js';
3
4
  /** The HIMI firmware's own word for "your session is gone". */
4
5
  export const UFI_SESSION_REFUSAL = 'SessionOut';
@@ -9,22 +10,7 @@ const ufiReplySchema = z.object({
9
10
  params: flatRecordSchema.optional(),
10
11
  });
11
12
  export function parseUfiReply(body) {
12
- const parsed = z
13
- .string()
14
- .transform((value, context) => {
15
- try {
16
- return JSON.parse(value);
17
- }
18
- catch (error) {
19
- if (!(error instanceof SyntaxError))
20
- throw error;
21
- context.addIssue({ code: 'custom', message: 'invalid JSON' });
22
- return z.NEVER;
23
- }
24
- })
25
- .pipe(ufiReplySchema)
26
- .safeParse(body);
27
- return parsed.success ? parsed.data : undefined;
13
+ return parseJsonWith(ufiReplySchema, body);
28
14
  }
29
15
  /**
30
16
  * Session acquisition, bounded to ONE login per physical modem per generation.
@@ -1,24 +1,10 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { z } from 'zod';
3
+ import { parseJsonWith } from '../../json-boundary.js';
3
4
  import { ZTE_EVIDENCE_CMD, ZTE_PATHS, ZTE_UNKNOWN_PROFILE, zteProfileById, zteProfilesForFirmware, } from './provider.js';
4
5
  const flatRecordSchema = z.record(z.string(), z.union([z.string(), z.number()]));
5
6
  export function parseZteRecord(body) {
6
- const result = z
7
- .string()
8
- .transform((value, context) => {
9
- try {
10
- return JSON.parse(value);
11
- }
12
- catch (error) {
13
- if (!(error instanceof SyntaxError))
14
- throw error;
15
- context.addIssue({ code: 'custom', message: 'invalid JSON' });
16
- return z.NEVER;
17
- }
18
- })
19
- .pipe(flatRecordSchema)
20
- .safeParse(body);
21
- return result.success ? result.data : undefined;
7
+ return parseJsonWith(flatRecordSchema, body);
22
8
  }
23
9
  function stokCookie(response) {
24
10
  const expected = 'set-cookie';
@@ -8,16 +8,26 @@ export class FlockResourceOwnershipError extends Error {
8
8
  this.detail = detail;
9
9
  }
10
10
  }
11
+ const STARTUP_DEADLINE_MS = 1_000;
12
+ const STARTUP_OUTPUT_LIMIT_BYTES = 64 * 1024;
11
13
  export function createFlockResourceOwnershipPort(options) {
12
14
  return {
13
15
  async acquire() {
14
16
  const child = spawn(options.flockBinary ?? 'flock', ['--exclusive', '--nonblock', '--no-fork', options.lockPath, '/bin/cat'], { stdio: ['pipe', 'pipe', 'pipe'] });
17
+ const closed = childClosed(child);
15
18
  if (child.pid === undefined) {
19
+ await reapChild(child, closed);
16
20
  throw new FlockResourceOwnershipError('helper started without a process id');
17
21
  }
18
22
  const holder = { pid: child.pid, startedAtEpochMs: Date.now() };
19
- const closed = childClosed(child);
20
- const started = await childStarted(child, options.lockPath, holder);
23
+ let started;
24
+ try {
25
+ started = await childStarted(child, options.lockPath, holder);
26
+ }
27
+ catch (error) {
28
+ await reapChild(child, closed);
29
+ throw error;
30
+ }
21
31
  if (started.status === 'closed') {
22
32
  if (started.code === 1) {
23
33
  const holder = await readHolder(options.lockPath);
@@ -55,36 +65,86 @@ function createLease(child, closed, holder) {
55
65
  function childClosed(child) {
56
66
  return new Promise((resolve) => child.once('close', () => resolve()));
57
67
  }
68
+ async function reapChild(child, closed) {
69
+ if (child.exitCode === null && child.signalCode === null) {
70
+ child.kill('SIGKILL');
71
+ }
72
+ await closed;
73
+ }
58
74
  function childStarted(child, lockPath, holder) {
59
75
  return new Promise((resolve, reject) => {
60
76
  let stdout = '';
61
77
  let stderr = '';
78
+ let stdoutBytes = 0;
79
+ let stderrBytes = 0;
80
+ let acknowledged = false;
62
81
  let settled = false;
82
+ const startupDeadline = setTimeout(() => fail(new FlockResourceOwnershipError('helper startup deadline exceeded')), STARTUP_DEADLINE_MS);
83
+ const cleanup = () => {
84
+ clearTimeout(startupDeadline);
85
+ child.stdout.off('data', onStdout);
86
+ child.stderr.off('data', onStderr);
87
+ child.off('close', onClose);
88
+ child.off('error', fail);
89
+ child.stdin.off('error', fail);
90
+ };
63
91
  const finish = (result) => {
64
92
  if (settled)
65
93
  return;
66
94
  settled = true;
95
+ cleanup();
67
96
  resolve(result);
68
97
  };
69
- child.stderr.on('data', (chunk) => {
98
+ const fail = (error) => {
99
+ if (settled)
100
+ return;
101
+ settled = true;
102
+ cleanup();
103
+ reject(error);
104
+ };
105
+ const onStderr = (chunk) => {
106
+ stderrBytes += chunk.length;
107
+ if (stderrBytes > STARTUP_OUTPUT_LIMIT_BYTES) {
108
+ fail(new FlockResourceOwnershipError('helper startup output limit exceeded on stderr'));
109
+ return;
110
+ }
70
111
  stderr += chunk.toString('utf8');
71
- });
72
- child.stdout.on('data', (chunk) => {
112
+ };
113
+ const onStdout = (chunk) => {
114
+ stdoutBytes += chunk.length;
115
+ if (stdoutBytes > STARTUP_OUTPUT_LIMIT_BYTES) {
116
+ fail(new FlockResourceOwnershipError('helper startup output limit exceeded on stdout'));
117
+ return;
118
+ }
73
119
  stdout += chunk.toString('utf8');
74
120
  const newline = stdout.indexOf('\n');
75
121
  if (newline < 0)
76
122
  return;
77
123
  const acquired = parseAcquiredLine(stdout.slice(0, newline));
78
- if (acquired === undefined || settled)
124
+ if (acquired === undefined || acknowledged)
79
125
  return;
80
- settled = true;
81
- void writeFile(lockPath, `${JSON.stringify(holder)}\n`, { mode: 0o600 }).then(() => resolve({ status: 'acquired', holder }), (error) => {
82
- child.stdin.end();
83
- reject(error);
126
+ acknowledged = true;
127
+ void writeFile(lockPath, `${JSON.stringify(holder)}\n`, { mode: 0o600 }).then(() => finish({ status: 'acquired', holder }), fail);
128
+ };
129
+ const onClose = (code) => finish({ status: 'closed', code, stderr: stderr.trim() });
130
+ child.stderr.on('data', onStderr);
131
+ child.stdout.on('data', onStdout);
132
+ child.once('close', onClose);
133
+ child.once('error', fail);
134
+ child.stdin.once('error', fail);
135
+ try {
136
+ child.stdin.write(`${JSON.stringify({ type: 'acquired', holder })}\n`, (error) => {
137
+ if (error !== null)
138
+ fail(error);
84
139
  });
85
- });
86
- child.once('close', (code) => finish({ status: 'closed', code, stderr: stderr.trim() }));
87
- child.stdin.write(`${JSON.stringify({ type: 'acquired', holder })}\n`);
140
+ }
141
+ catch (error) {
142
+ if (error instanceof Error) {
143
+ fail(error);
144
+ return;
145
+ }
146
+ fail(new FlockResourceOwnershipError('helper stdin failed with a non-error value'));
147
+ }
88
148
  });
89
149
  }
90
150
  function parseAcquiredLine(line) {
@@ -1,2 +1,21 @@
1
+ import { type CreateClientOptions, type RawBus } from './dbus-native.js';
2
+ import { TransportError } from './errors.js';
1
3
  import type { DbusTransport, DbusTransportOptions } from './types.js';
2
- export declare function createDbusTransport(options?: DbusTransportOptions): DbusTransport;
4
+ export interface TransportTimingPolicy {
5
+ readonly connectTimeoutMs: number;
6
+ readonly callTimeoutMs: number;
7
+ }
8
+ export declare const DEFAULT_TRANSPORT_TIMING: TransportTimingPolicy;
9
+ export interface DbusTransportSeamOptions extends DbusTransportOptions {
10
+ readonly timing?: Partial<TransportTimingPolicy>;
11
+ }
12
+ export type TransportTeardownPhase = 'disconnect' | 'establish-abort';
13
+ export type TransportTeardownStep = 'bus-disconnect' | 'connection-end';
14
+ export declare class TransportTeardownFailure extends TransportError {
15
+ readonly phase: TransportTeardownPhase;
16
+ readonly step: TransportTeardownStep;
17
+ constructor(phase: TransportTeardownPhase, step: TransportTeardownStep, cause: unknown);
18
+ }
19
+ export type BusFactory = (options: CreateClientOptions) => RawBus;
20
+ export declare function createDbusTransport(options?: DbusTransportSeamOptions): DbusTransport;
21
+ export declare function createDbusTransportForTest(options: DbusTransportSeamOptions, busFactory: BusFactory): DbusTransport;
@@ -10,33 +10,65 @@ import { CallDispatcher, DEFAULT_CALL_TIMEOUT_MS } from './calls.js';
10
10
  import { createClient, } from './dbus-native.js';
11
11
  import { DisconnectedError, TransportError } from './errors.js';
12
12
  import { SignalRegistry } from './signals.js';
13
- // Bound a single connect/auth attempt so a stalled handshake cannot freeze the reconnect
14
- // loop. A local unix-socket D-Bus connect completes in milliseconds; 2s is ample headroom
15
- // while keeping reconnect responsive after a bus restart.
16
- const CONNECT_TIMEOUT_MS = 2_000;
17
13
  const DEFAULT_RECONNECT = {
18
14
  enabled: true,
19
15
  initialDelayMs: 50,
20
16
  maxDelayMs: 2_000,
21
17
  maxAttempts: 0,
22
18
  };
23
- const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
19
+ export const DEFAULT_TRANSPORT_TIMING = {
20
+ connectTimeoutMs: 2_000,
21
+ callTimeoutMs: DEFAULT_CALL_TIMEOUT_MS,
22
+ };
23
+ function resolveTimingPolicy(options) {
24
+ return {
25
+ connectTimeoutMs: options.timing?.connectTimeoutMs ?? DEFAULT_TRANSPORT_TIMING.connectTimeoutMs,
26
+ callTimeoutMs: options.timing?.callTimeoutMs ??
27
+ options.callTimeoutMs ??
28
+ DEFAULT_TRANSPORT_TIMING.callTimeoutMs,
29
+ };
30
+ }
31
+ // A teardown step failed. This is REPORTED on the transport's existing `error` event and is
32
+ // never thrown: by the time it happens the caller is already closing (or the establish error
33
+ // is already on its way up), so escalating would replace the failure a caller must act on with
34
+ // one they cannot. It is an `Error` subclass because everything else on that event is —
35
+ // consumers narrow with `instanceof` and branch on `phase` / `step`.
36
+ export class TransportTeardownFailure extends TransportError {
37
+ phase;
38
+ step;
39
+ constructor(phase, step, cause) {
40
+ super(`D-Bus transport teardown step "${step}" failed during ${phase}`, { cause });
41
+ this.name = 'TransportTeardownFailure';
42
+ this.phase = phase;
43
+ this.step = step;
44
+ }
45
+ }
24
46
  class DbusTransportImpl {
25
47
  #options;
26
48
  #reconnect;
49
+ #timing;
50
+ #createBus;
27
51
  #emitter = new EventEmitter();
28
52
  #calls;
29
53
  #signals;
30
54
  #bus = null;
31
55
  #state = 'idle';
32
56
  #closing = false;
57
+ // The single live reconnect loop, retained rather than fired and forgotten: a second drop
58
+ // cannot start a second loop, and `disconnect()` has something to await so it can promise
59
+ // that no loop outlives it.
60
+ #reconnectLoopPromise = null;
61
+ // Set only while that loop is parked in its backoff sleep; calling it cuts the sleep short.
62
+ #wakeBackoff = null;
33
63
  // Bound once so the same references can be detached from a dead connection.
34
64
  #onMessage = (message) => this.#signals.dispatch(message);
35
65
  #onConnectionError = (cause) => this.#handleDrop(cause instanceof Error ? cause : new DisconnectedError(String(cause)));
36
66
  #onConnectionEnd = () => this.#handleDrop(new DisconnectedError('bus connection ended'));
37
- constructor(options) {
67
+ constructor(options, createBus) {
38
68
  this.#options = options;
39
- this.#calls = new CallDispatcher(options.callTimeoutMs ?? DEFAULT_CALL_TIMEOUT_MS);
69
+ this.#createBus = createBus;
70
+ this.#timing = resolveTimingPolicy(options);
71
+ this.#calls = new CallDispatcher(this.#timing.callTimeoutMs);
40
72
  this.#signals = new SignalRegistry({
41
73
  currentBus: () => this.#bus,
42
74
  isConnected: () => this.#state === 'connected',
@@ -70,10 +102,17 @@ class DbusTransportImpl {
70
102
  this.#state = 'closed';
71
103
  const bus = this.#bus;
72
104
  this.#bus = null;
105
+ // Capture the loop BEFORE waking it: waking lets it run to completion, which clears the
106
+ // field, and a `disconnect()` that lost the handle could not wait for it.
107
+ const loop = this.#reconnectLoopPromise;
108
+ this.#cancelBackoff();
73
109
  this.#calls.rejectAll(new DisconnectedError('transport closed'));
74
110
  if (bus) {
75
111
  this.#quiesce(bus);
76
- await bus.disconnect().catch(() => undefined);
112
+ await this.#closeBus(bus, 'disconnect');
113
+ }
114
+ if (loop) {
115
+ await loop;
77
116
  }
78
117
  }
79
118
  callMethod(call) {
@@ -100,7 +139,8 @@ class DbusTransportImpl {
100
139
  else if (this.#options.busAddress !== undefined) {
101
140
  options.busAddress = this.#options.busAddress;
102
141
  }
103
- const bus = createClient(options);
142
+ const connectTimeoutMs = this.#timing.connectTimeoutMs;
143
+ const bus = this.#createBus(options);
104
144
  try {
105
145
  await new Promise((resolve, reject) => {
106
146
  const onConnect = () => {
@@ -113,8 +153,8 @@ class DbusTransportImpl {
113
153
  };
114
154
  const timer = setTimeout(() => {
115
155
  cleanup();
116
- reject(new TransportError(`bus connect timed out after ${CONNECT_TIMEOUT_MS}ms`));
117
- }, CONNECT_TIMEOUT_MS);
156
+ reject(new TransportError(`bus connect timed out after ${connectTimeoutMs}ms`));
157
+ }, connectTimeoutMs);
118
158
  const cleanup = () => {
119
159
  clearTimeout(timer);
120
160
  bus.connection.removeListener('connect', onConnect);
@@ -128,6 +168,12 @@ class DbusTransportImpl {
128
168
  bus.connection.on('end', this.#onConnectionEnd);
129
169
  // Re-issue every live match rule so a reconnect resubscribes transparently.
130
170
  await this.#signals.reissueRules(bus);
171
+ if (this.#closing) {
172
+ // `disconnect()` landed while this attempt was in flight. Fail the attempt so the
173
+ // shared catch below tears the fresh connection down — a closed transport must
174
+ // never publish a live bus that nobody is left to close.
175
+ throw new TransportError('Transport is closed');
176
+ }
131
177
  this.#bus = bus;
132
178
  this.#state = 'connected';
133
179
  }
@@ -138,8 +184,12 @@ class DbusTransportImpl {
138
184
  try {
139
185
  bus.connection.end();
140
186
  }
141
- catch {
142
- // The half-open connection is already dead; nothing to close.
187
+ catch (cause) {
188
+ // The half-open connection is usually already dead, so this ordinarily does
189
+ // nothing. When it does fail, that is a real teardown outcome and it is reported
190
+ // rather than discarded — but never rethrown, because `error` is the failure the
191
+ // caller actually needs to see.
192
+ this.#reportTeardownFailure('establish-abort', 'connection-end', cause);
143
193
  }
144
194
  throw error;
145
195
  }
@@ -156,6 +206,27 @@ class DbusTransportImpl {
156
206
  this.#detachHandlers(bus);
157
207
  bus.connection.on('error', () => undefined);
158
208
  }
209
+ // A rejected `bus.disconnect()` used to vanish into `.catch(() => undefined)`, leaving a
210
+ // consumer no way to learn that a socket it believed closed never actually was.
211
+ async #closeBus(bus, phase) {
212
+ try {
213
+ await bus.disconnect();
214
+ }
215
+ catch (cause) {
216
+ this.#reportTeardownFailure(phase, 'bus-disconnect', cause);
217
+ }
218
+ }
219
+ #reportTeardownFailure(phase, step, cause) {
220
+ this.#emitObservable('error', new TransportTeardownFailure(phase, step, cause));
221
+ }
222
+ // Node re-throws an unobserved EventEmitter 'error', which would turn a report ABOUT a
223
+ // failed teardown into a process crash on the path that is already unwinding.
224
+ #emitObservable(event, payload) {
225
+ if (event === 'error' && this.#emitter.listenerCount('error') === 0) {
226
+ return;
227
+ }
228
+ this.#emitter.emit(event, payload);
229
+ }
159
230
  #handleDrop(cause) {
160
231
  if (this.#closing) {
161
232
  return;
@@ -171,8 +242,25 @@ class DbusTransportImpl {
171
242
  this.#calls.rejectAll(cause);
172
243
  this.#emitter.emit('disconnected', cause);
173
244
  if (this.#reconnect.enabled) {
174
- void this.#reconnectLoop();
245
+ this.#startReconnectLoop();
246
+ }
247
+ }
248
+ // The state guard above already blocks the common double-drop, but holding the promise
249
+ // makes the single-loop property structural instead of a consequence of state ordering,
250
+ // and it is what lets `disconnect()` wait for the loop it just cancelled.
251
+ #startReconnectLoop() {
252
+ if (this.#reconnectLoopPromise !== null) {
253
+ return;
175
254
  }
255
+ const loop = this.#reconnectLoop().catch((error) => {
256
+ this.#emitObservable('error', error);
257
+ });
258
+ this.#reconnectLoopPromise = loop;
259
+ void loop.then(() => {
260
+ if (this.#reconnectLoopPromise === loop) {
261
+ this.#reconnectLoopPromise = null;
262
+ }
263
+ });
176
264
  }
177
265
  async #reconnectLoop() {
178
266
  this.#state = 'reconnecting';
@@ -191,12 +279,41 @@ class DbusTransportImpl {
191
279
  this.#emitter.emit('error', error);
192
280
  return;
193
281
  }
194
- await sleep(delay);
282
+ await this.#backoff(delay);
195
283
  delay = Math.min(delay * 2, this.#reconnect.maxDelayMs);
196
284
  }
197
285
  }
198
286
  }
287
+ // A plain `setTimeout` promise holds the loop — and the event loop — for the whole
288
+ // remaining delay after a caller has already asked for teardown. At the default 2s ceiling
289
+ // that is a 2s stall on every close landing mid-backoff, and an unbounded one for a caller
290
+ // that raised the ceiling.
291
+ #backoff(ms) {
292
+ return new Promise((resolve) => {
293
+ const finish = () => {
294
+ clearTimeout(timer);
295
+ if (this.#wakeBackoff === finish) {
296
+ this.#wakeBackoff = null;
297
+ }
298
+ resolve();
299
+ };
300
+ const timer = setTimeout(finish, ms);
301
+ this.#wakeBackoff = finish;
302
+ });
303
+ }
304
+ #cancelBackoff() {
305
+ const wake = this.#wakeBackoff;
306
+ this.#wakeBackoff = null;
307
+ wake?.();
308
+ }
199
309
  }
200
310
  export function createDbusTransport(options = {}) {
201
- return new DbusTransportImpl(options);
311
+ return new DbusTransportImpl(options, createClient);
312
+ }
313
+ // Deliberately NOT re-exported from `./index.ts`, whose public surface is pinned by
314
+ // `no-library-leak.test.ts`. It exists so lifecycle failure paths — a bus whose `disconnect()`
315
+ // rejects, a connection whose `end()` throws, a handshake that never completes — are reachable
316
+ // deterministically, without a real `dbus-daemon` to break.
317
+ export function createDbusTransportForTest(options, busFactory) {
318
+ return new DbusTransportImpl(options, busFactory);
202
319
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ceralive/modem-control",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "type": "module",
5
5
  "description": "Cellular modem control for CeraLive — ModemManager D-Bus backend, NetworkManager adapter, desired-state reconciler, USB composition-mode model, data-usage sampler.",
6
6
  "license": "AGPL-3.0",