@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.
- package/README.md +84 -0
- package/dist/backend/cell-info.d.ts +2 -0
- package/dist/backend/cell-info.js +18 -2
- package/dist/backend/device-classifier.d.ts +56 -0
- package/dist/backend/device-classifier.js +88 -0
- package/dist/backend/mm-mutations/bands.d.ts +4 -0
- package/dist/backend/mm-mutations/bands.js +49 -0
- package/dist/backend/mm-mutations/context.d.ts +10 -0
- package/dist/backend/mm-mutations/context.js +3 -0
- package/dist/backend/mm-mutations/inhibit.d.ts +4 -0
- package/dist/backend/mm-mutations/inhibit.js +19 -0
- package/dist/backend/mm-mutations/modes.d.ts +5 -0
- package/dist/backend/mm-mutations/modes.js +44 -0
- package/dist/backend/mm-mutations/scan.d.ts +3 -0
- package/dist/backend/mm-mutations/scan.js +46 -0
- package/dist/backend/mm-mutations/sim.d.ts +5 -0
- package/dist/backend/mm-mutations/sim.js +49 -0
- package/dist/backend/mm-mutations.d.ts +0 -16
- package/dist/backend/mm-mutations.js +27 -234
- package/dist/backend/observer/bus-lifecycle.d.ts +7 -0
- package/dist/backend/observer/bus-lifecycle.js +12 -0
- package/dist/backend/observer/epoch-reconcile.d.ts +4 -0
- package/dist/backend/observer/epoch-reconcile.js +28 -0
- package/dist/backend/observer/signal-routing.d.ts +11 -0
- package/dist/backend/observer/signal-routing.js +11 -0
- package/dist/backend/observer.d.ts +1 -1
- package/dist/backend/observer.js +18 -35
- package/dist/backend/router-ethernet.d.ts +7 -0
- package/dist/backend/router-ethernet.js +70 -18
- package/dist/backend/signal-setup.d.ts +21 -2
- package/dist/backend/signal-setup.js +24 -2
- package/dist/backend/usage/accounting.d.ts +7 -0
- package/dist/backend/usage/accounting.js +8 -2
- package/dist/backend/usage/persistence.d.ts +4 -0
- package/dist/backend/usage/persistence.js +59 -0
- package/dist/backend/usage/policy.d.ts +24 -0
- package/dist/backend/usage/policy.js +38 -0
- package/dist/backend/usage/sampler.d.ts +7 -1
- package/dist/backend/usage/sampler.js +33 -119
- package/dist/backend/usage/sampling.d.ts +38 -0
- package/dist/backend/usage/sampling.js +72 -0
- package/dist/backend/usb-mode-transition/admission.d.ts +5 -0
- package/dist/backend/usb-mode-transition/admission.js +12 -0
- package/dist/backend/usb-mode-transition/at.d.ts +13 -0
- package/dist/backend/usb-mode-transition/at.js +15 -0
- package/dist/backend/usb-mode-transition/outcome.d.ts +4 -0
- package/dist/backend/usb-mode-transition/outcome.js +17 -0
- package/dist/backend/usb-mode-transition/reenumeration.d.ts +8 -0
- package/dist/backend/usb-mode-transition/reenumeration.js +37 -0
- package/dist/backend/usb-mode-transition.d.ts +2 -2
- package/dist/backend/usb-mode-transition.js +25 -71
- package/dist/domain/mm-enums.d.ts +37 -0
- package/dist/domain/mm-enums.js +34 -0
- package/dist/fcc/coverage.d.ts +7 -0
- package/dist/fcc/coverage.js +7 -0
- package/dist/hardware/router-parsers.js +3 -32
- package/dist/json-boundary.d.ts +4 -0
- package/dist/json-boundary.js +19 -0
- package/dist/observations/model.d.ts +45 -0
- package/dist/observations/raw.d.ts +12 -0
- package/dist/observations/raw.js +31 -0
- package/dist/observations/sources/hilink.js +7 -1
- package/dist/observations/sources/modemmanager.d.ts +25 -0
- package/dist/observations/sources/modemmanager.js +123 -15
- package/dist/observations/sources/router-shared.d.ts +35 -1
- package/dist/observations/sources/router-shared.js +39 -0
- package/dist/observations/sources/ufi.js +7 -1
- package/dist/observations/sources/zte.js +7 -1
- package/dist/providers/modem-manager/provider.d.ts +10 -0
- package/dist/providers/modem-manager/provider.js +3 -0
- package/dist/providers/modem-manager/runtime-composition-operation.js +2 -0
- package/dist/providers/modem-manager/snapshot.js +6 -0
- package/dist/providers/network-manager/adapter.js +15 -136
- package/dist/providers/network-manager/divergence.d.ts +4 -0
- package/dist/providers/network-manager/divergence.js +7 -0
- package/dist/providers/network-manager/observe-fold.d.ts +9 -0
- package/dist/providers/network-manager/observe-fold.js +109 -0
- package/dist/providers/network-manager/projection.d.ts +5 -0
- package/dist/providers/network-manager/projection.js +14 -0
- package/dist/providers/network-manager/state.d.ts +10 -0
- package/dist/providers/network-manager/state.js +16 -0
- package/dist/providers/ufi-himi/session.js +2 -16
- package/dist/providers/zte-goform/session.js +2 -16
- package/dist/safety/flock-resource-ownership.js +73 -13
- package/dist/transport/transport.d.ts +20 -1
- package/dist/transport/transport.js +133 -16
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
20
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ||
|
|
124
|
+
if (acquired === undefined || acknowledged)
|
|
79
125
|
return;
|
|
80
|
-
|
|
81
|
-
void writeFile(lockPath, `${JSON.stringify(holder)}\n`, { mode: 0o600 }).then(() =>
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
87
|
-
|
|
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
|
|
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
|
|
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.#
|
|
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
|
|
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
|
|
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 ${
|
|
117
|
-
},
|
|
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
|
|
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
|
-
|
|
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
|
|
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.
|
|
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",
|