@meshbench/client 0.0.1
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 +216 -0
- package/lib/boundary.mjs +123 -0
- package/lib/checks.mjs +178 -0
- package/lib/device.mjs +97 -0
- package/lib/errors.mjs +150 -0
- package/lib/firmware.mjs +249 -0
- package/lib/launch.mjs +112 -0
- package/lib/live.mjs +103 -0
- package/lib/nodes.mjs +416 -0
- package/lib/pairing.mjs +65 -0
- package/lib/parts.mjs +190 -0
- package/lib/sessions.mjs +122 -0
- package/lib/sets.mjs +275 -0
- package/lib/sim.mjs +125 -0
- package/lib/socket.mjs +311 -0
- package/lib/subscribe.mjs +86 -0
- package/lib/values.mjs +66 -0
- package/lib/wait.mjs +68 -0
- package/lib/workbench.mjs +376 -0
- package/meshbench.mjs +82 -0
- package/package.json +31 -0
package/lib/errors.mjs
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// What went wrong, in a shape a script can catch.
|
|
2
|
+
//
|
|
3
|
+
// The workbench answers with a sentence and a code. The sentence is the good
|
|
4
|
+
// part - "no node is running firmware, so there is nothing to send to" - and it
|
|
5
|
+
// survives untouched; the code decides which class it becomes, because
|
|
6
|
+
// `instanceof` is what JavaScript has where Go has errors.Is and Python has an
|
|
7
|
+
// exception hierarchy. A caller that matched on prose would break the day the
|
|
8
|
+
// workbench reworded a refusal.
|
|
9
|
+
|
|
10
|
+
import { PROTOCOL, RELEASE } from "./pairing.mjs";
|
|
11
|
+
|
|
12
|
+
/** Anything this client throws. */
|
|
13
|
+
export class MeshbenchError extends Error {
|
|
14
|
+
constructor(message) {
|
|
15
|
+
super(message);
|
|
16
|
+
this.name = "MeshbenchError";
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** A verb the workbench declined, with its own words kept.
|
|
21
|
+
*
|
|
22
|
+
* Named for the workbench rather than for the refusal because that is what a
|
|
23
|
+
* Node script has caught since the first release, and the name is the API. */
|
|
24
|
+
export class WorkbenchError extends MeshbenchError {
|
|
25
|
+
/** Carries the code beside the message rather than folding it into the
|
|
26
|
+
* prose, because a caller that has to match on prose breaks the day the
|
|
27
|
+
* workbench rewords a refusal. */
|
|
28
|
+
constructor(verb, message, code) {
|
|
29
|
+
super(verb ? `${verb}: ${message}` : message);
|
|
30
|
+
this.name = "WorkbenchError";
|
|
31
|
+
/** Which verb was refused, so a log line says what was asked as well as
|
|
32
|
+
* what came back. Empty for a refusal that answered no verb - the
|
|
33
|
+
* connection itself being turned away. */
|
|
34
|
+
this.verb = verb || "";
|
|
35
|
+
/** The workbench's own words, unaltered and without the verb in front. */
|
|
36
|
+
this.detail = message;
|
|
37
|
+
/** How the refusal was classified, so a caller can branch on it instead of
|
|
38
|
+
* on prose: the workbench's own code (`not_found`, `conflict`, `closing`
|
|
39
|
+
* and the rest the control socket defines) when a verb was refused, and
|
|
40
|
+
* `protocol_mismatch` or `version_mismatch` when this client was the end
|
|
41
|
+
* that refused, at the handshake - the same two the workbench uses, so a
|
|
42
|
+
* script branching on the code need not care which end noticed. Empty when
|
|
43
|
+
* a refusal arrived without one, so test it rather than assume it is set. */
|
|
44
|
+
this.code = code || "";
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Not a method this build has.
|
|
49
|
+
*
|
|
50
|
+
* Nearly always a client older or newer than the workbench, which connecting
|
|
51
|
+
* is supposed to have caught first, so seeing this is worth looking into. */
|
|
52
|
+
export class UnknownVerb extends WorkbenchError {}
|
|
53
|
+
|
|
54
|
+
/** The verb refused what it was given. */
|
|
55
|
+
export class BadParams extends WorkbenchError {}
|
|
56
|
+
|
|
57
|
+
/** No node, build, area or job of that name. */
|
|
58
|
+
export class NotFound extends WorkbenchError {}
|
|
59
|
+
|
|
60
|
+
/** The right request in the wrong state: nothing loaded, nothing running to
|
|
61
|
+
* send to, no import preview to commit. */
|
|
62
|
+
export class Conflict extends WorkbenchError {}
|
|
63
|
+
|
|
64
|
+
/** A request this session cannot serve at all - a window verb with no window,
|
|
65
|
+
* or hardware that is not here. */
|
|
66
|
+
export class Unavailable extends WorkbenchError {}
|
|
67
|
+
|
|
68
|
+
/** The workbench is shutting down. Retry against a new session rather than
|
|
69
|
+
* report a bug. */
|
|
70
|
+
export class Closing extends WorkbenchError {}
|
|
71
|
+
|
|
72
|
+
const BY_CODE = {
|
|
73
|
+
unknown_verb: UnknownVerb,
|
|
74
|
+
bad_params: BadParams,
|
|
75
|
+
not_found: NotFound,
|
|
76
|
+
conflict: Conflict,
|
|
77
|
+
unavailable: Unavailable,
|
|
78
|
+
closing: Closing,
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/** The right class for a code.
|
|
82
|
+
*
|
|
83
|
+
* An unrecognised code becomes a plain WorkbenchError rather than an error
|
|
84
|
+
* about the error: a workbench newer than this client may classify something
|
|
85
|
+
* in a way this version has never heard of, and swallowing that would be worse
|
|
86
|
+
* than passing it on. */
|
|
87
|
+
export function refusal(verb, message, code) {
|
|
88
|
+
const Cls = BY_CODE[code] || WorkbenchError;
|
|
89
|
+
const e = new Cls(verb, message, code);
|
|
90
|
+
e.name = Cls.name;
|
|
91
|
+
return e;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** A client and a workbench that cannot speak to each other's frames.
|
|
95
|
+
*
|
|
96
|
+
* Its own class rather than a WorkbenchError carrying a code, because a
|
|
97
|
+
* script has to be able to tell "these two cannot talk" from "this build
|
|
98
|
+
* declined what I asked" with `instanceof`, and the two remedies have nothing
|
|
99
|
+
* in common. */
|
|
100
|
+
export class ProtocolMismatch extends WorkbenchError {
|
|
101
|
+
constructor(message, { client = PROTOCOL, workbench = 0 } = {}) {
|
|
102
|
+
super("", message, "protocol_mismatch");
|
|
103
|
+
this.name = "ProtocolMismatch";
|
|
104
|
+
/** The wire version each end speaks. `workbench` is 0 when the workbench
|
|
105
|
+
* refused the connection before it would say what it was. */
|
|
106
|
+
this.client = client;
|
|
107
|
+
this.workbench = workbench;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** A released client driving a workbench from a different release.
|
|
112
|
+
*
|
|
113
|
+
* Separate from ProtocolMismatch: two ends can understand each other's frames
|
|
114
|
+
* perfectly and still be a pair nobody ever built or tested together. */
|
|
115
|
+
export class VersionMismatch extends WorkbenchError {
|
|
116
|
+
constructor(message, { client = RELEASE, workbench = "" } = {}) {
|
|
117
|
+
super("", message, "version_mismatch");
|
|
118
|
+
this.name = "VersionMismatch";
|
|
119
|
+
/** The release each end belongs to. `workbench` is empty when the
|
|
120
|
+
* workbench refused before it would say what it was. */
|
|
121
|
+
this.client = client;
|
|
122
|
+
this.workbench = workbench;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** A wait that ran out, saying what it wanted and what it last saw.
|
|
127
|
+
*
|
|
128
|
+
* Not a bare deadline: "timeout" in a CI log tells whoever reads it nothing,
|
|
129
|
+
* and the state at the moment it gave up is the only thing that does. */
|
|
130
|
+
export class Timeout extends MeshbenchError {
|
|
131
|
+
constructor(what, afterMs, last = "") {
|
|
132
|
+
super(`waited ${Math.round(afterMs / 1000)}s for ${what}` +
|
|
133
|
+
(last ? `; last saw: ${last}` : ""));
|
|
134
|
+
this.name = "Timeout";
|
|
135
|
+
this.what = what;
|
|
136
|
+
this.afterMs = afterMs;
|
|
137
|
+
this.last = last;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** The workbench's refusal of what this client declared, as the mismatch it is
|
|
142
|
+
* rather than as whichever call happened to be in flight failing - which is
|
|
143
|
+
* the confusion the declaration exists to end. Everything else is left alone. */
|
|
144
|
+
export function asMismatch(e) {
|
|
145
|
+
if (e instanceof ProtocolMismatch || e instanceof VersionMismatch) return e;
|
|
146
|
+
if (!(e instanceof WorkbenchError)) return e;
|
|
147
|
+
if (e.code === "protocol_mismatch") return new ProtocolMismatch(e.detail);
|
|
148
|
+
if (e.code === "version_mismatch") return new VersionMismatch(e.detail);
|
|
149
|
+
return e;
|
|
150
|
+
}
|
package/lib/firmware.mjs
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
// What this machine can run, and what it is running.
|
|
2
|
+
|
|
3
|
+
import { NotFound, refusal } from "./errors.mjs";
|
|
4
|
+
import { FIRMWARE_WAIT_MS, JOB_WAIT_MS, waitFor } from "./wait.mjs";
|
|
5
|
+
import { describeBuild } from "./values.mjs";
|
|
6
|
+
|
|
7
|
+
/** Which build a call means, from either a library row or a bare label.
|
|
8
|
+
*
|
|
9
|
+
* A row carries all three names and they are sent together, so the call cannot
|
|
10
|
+
* land on a different build that happens to share a label. A bare label sends
|
|
11
|
+
* only what was given, and the workbench refuses it when it is ambiguous
|
|
12
|
+
* rather than guessing - acting on the wrong build is a rename of somebody
|
|
13
|
+
* else's image. */
|
|
14
|
+
function buildID(build, { board = "", role = "" } = {}) {
|
|
15
|
+
if (build && typeof build === "object") {
|
|
16
|
+
return { version: build.version, role: build.role, board: build.board };
|
|
17
|
+
}
|
|
18
|
+
const p = { version: build };
|
|
19
|
+
if (role) p.role = role;
|
|
20
|
+
if (board) p.board = board;
|
|
21
|
+
return p;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** What this machine can run, and what it is running. Live. */
|
|
25
|
+
export class Firmware {
|
|
26
|
+
constructor(wb) { this._wb = wb; }
|
|
27
|
+
|
|
28
|
+
/** Every build, published or on disk, with what runs it - the rows, not a
|
|
29
|
+
* count of them. */
|
|
30
|
+
async library() {
|
|
31
|
+
return ((await this._wb.call("firmware.library")) || {}).builds || [];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Only the ones this machine holds, which is the only thing that decides
|
|
35
|
+
* what a node can run. A build that failed to download and one in daily use
|
|
36
|
+
* look identical from anywhere else. */
|
|
37
|
+
async onDisk() {
|
|
38
|
+
return (await this.library()).filter((b) => b.on_disk);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** One build by version, and by board where the version alone is ambiguous -
|
|
42
|
+
* which it is for every board image, because "wadamesh" is not a build until
|
|
43
|
+
* it is wadamesh for a particular piece of hardware. */
|
|
44
|
+
async find(version, board = "") {
|
|
45
|
+
for (const b of await this.library()) {
|
|
46
|
+
if (b.version === version && (!board || b.board === board)) return b;
|
|
47
|
+
}
|
|
48
|
+
throw new NotFound("firmware.library",
|
|
49
|
+
`no build "${version}" for board "${board}"`, "not_found");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Everything known about one build: where it is, what it is, and what has
|
|
53
|
+
* been decided about it. */
|
|
54
|
+
async details(build, opts) {
|
|
55
|
+
return (await this._wb.call("firmware.details", buildID(build, opts))) || {};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Rename a build, move it to another board or role, or change how it is run,
|
|
59
|
+
* and report it as it now stands.
|
|
60
|
+
*
|
|
61
|
+
* Renaming moves the file, because the name is the identity: a board image
|
|
62
|
+
* is stored as `<board>/<role>@<label>.bin` and nothing else records what it
|
|
63
|
+
* is. Nodes pinned to the old name are repointed, or they would fail at
|
|
64
|
+
* their next start with "no image in the cache" about a build sitting in the
|
|
65
|
+
* library under its new name.
|
|
66
|
+
*
|
|
67
|
+
* Every setting left out is left alone, which is why they are undefined
|
|
68
|
+
* rather than "" or false: "leave this" and "turn it off" are different
|
|
69
|
+
* answers. */
|
|
70
|
+
async update(build, {
|
|
71
|
+
board = "", role = "", label, newRole, newBoard,
|
|
72
|
+
coprocAtReset, cardRequired, notes,
|
|
73
|
+
} = {}) {
|
|
74
|
+
const p = buildID(build, { board, role });
|
|
75
|
+
if (label !== undefined) p.label = label;
|
|
76
|
+
if (newRole !== undefined) p.new_role = newRole;
|
|
77
|
+
if (newBoard !== undefined) p.new_board = newBoard;
|
|
78
|
+
if (coprocAtReset !== undefined) p.coproc_at_reset = coprocAtReset;
|
|
79
|
+
if (cardRequired !== undefined) p.card_required = cardRequired;
|
|
80
|
+
if (notes !== undefined) p.notes = notes;
|
|
81
|
+
const moved = (await this._wb.call("firmware.update", p)) || {};
|
|
82
|
+
// Read back under whatever it is called now, which is not what it was
|
|
83
|
+
// called if this was a rename.
|
|
84
|
+
return this.details(moved.version || "",
|
|
85
|
+
{ board: moved.board || "", role: moved.role || "" });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Open the build's own window - what a click on a library row does. Refused
|
|
89
|
+
* by a workbench with no interface. */
|
|
90
|
+
async window(build, opts) {
|
|
91
|
+
await this._wb.call("firmware.window", buildID(build, opts));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Ask the catalogue what is published, which is how a build nobody has
|
|
95
|
+
* downloaded becomes offerable. */
|
|
96
|
+
async scan() { await this._wb.call("firmware.rescan"); }
|
|
97
|
+
|
|
98
|
+
/** Fetch a published build. It returns once the download has been asked for,
|
|
99
|
+
* not once it has landed: wait on the job.
|
|
100
|
+
*
|
|
101
|
+
* `role` is a plain string here and a Role everywhere else, deliberately:
|
|
102
|
+
* this one names a published release asset, and the catalogue's own
|
|
103
|
+
* spellings are not always the application names the verbs are keyed on. */
|
|
104
|
+
async download(role, version, board = "") {
|
|
105
|
+
const p = { role, version };
|
|
106
|
+
if (board) p.board = board;
|
|
107
|
+
await this._wb.call("firmware.download", p);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Take a build from a path - the one way a locally built image gets into the
|
|
111
|
+
* library.
|
|
112
|
+
*
|
|
113
|
+
* `label` is what the library will know it by and what a node pins. Left out
|
|
114
|
+
* it is a timestamp, so importing twice gives two builds rather than one
|
|
115
|
+
* that quietly replaced the other - which matters the moment you want to put
|
|
116
|
+
* the new one on a node and delete the old. */
|
|
117
|
+
async import(path, role, { board = "", label = "" } = {}) {
|
|
118
|
+
const p = { path, role };
|
|
119
|
+
if (board) p.board = board;
|
|
120
|
+
if (label) p.label = label;
|
|
121
|
+
return (await this._wb.call("firmware.import", p)) || {};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Remove a build from the cache, and say what was removed.
|
|
125
|
+
*
|
|
126
|
+
* By path, and the workbench refuses any path outside the firmware cache. A
|
|
127
|
+
* build nodes are still pinned to will go: they keep the pin, which then
|
|
128
|
+
* cannot be honoured and fails at start - so move them onto the replacement
|
|
129
|
+
* first. */
|
|
130
|
+
async delete(build) {
|
|
131
|
+
if (!build || !build.path) {
|
|
132
|
+
throw refusal("firmware.delete",
|
|
133
|
+
`${describeBuild(build)} has no path on this machine to delete`,
|
|
134
|
+
"bad_params");
|
|
135
|
+
}
|
|
136
|
+
const got = await this._wb.call("firmware.delete", { path: build.path });
|
|
137
|
+
return (got || {}).deleted || "";
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Compile a MeshCore checkout and put the results in the library.
|
|
141
|
+
*
|
|
142
|
+
* Both roles unless one is named, deliberately. A locally built repeater
|
|
143
|
+
* compiled against a stale shim once answered console output with 0x06 where
|
|
144
|
+
* the host expects 0x07: it connected, misbehaved and exited. Two arms of a
|
|
145
|
+
* comparison built at different moments from different trees measure the
|
|
146
|
+
* build process rather than the firmware, so the easy thing here is the
|
|
147
|
+
* thing that builds them together.
|
|
148
|
+
*
|
|
149
|
+
* Blocks until it is done - a MeshCore build is a minute or two per role -
|
|
150
|
+
* and returns what the library now holds that was built locally. */
|
|
151
|
+
async build(checkout, { role = "", label = "", waitMs = JOB_WAIT_MS } = {}) {
|
|
152
|
+
const p = { source: checkout };
|
|
153
|
+
if (role) p.role = role;
|
|
154
|
+
if (label) p.label = label;
|
|
155
|
+
const got = (await this._wb.call("firmware.build", p)) || {};
|
|
156
|
+
await this._wb.job(got.job || "firmware-build").wait(waitMs);
|
|
157
|
+
return (await this.library()).filter((b) => b.version.startsWith("local-"));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Pin every role that needs one to the newest build on this machine, and
|
|
161
|
+
* report what it chose.
|
|
162
|
+
*
|
|
163
|
+
* What a script wants almost every time: this mesh, whatever this machine
|
|
164
|
+
* holds, rather than a version typed into the script that goes stale. A run
|
|
165
|
+
* refuses to start until every role is answered, so the alternative is the
|
|
166
|
+
* same loop written out in every caller.
|
|
167
|
+
*
|
|
168
|
+
* It refuses by name when a role has nothing, because "no companion build"
|
|
169
|
+
* is a thing to go and fix rather than a reason to start a mesh with a
|
|
170
|
+
* silent hole in it. */
|
|
171
|
+
async useWhatIsHere() {
|
|
172
|
+
const have = (await this.onDisk()).filter((b) => !b.board);
|
|
173
|
+
const chosen = {};
|
|
174
|
+
for (const want of await this.needed()) {
|
|
175
|
+
const pick = have.filter((b) => b.role === want.role).pop();
|
|
176
|
+
if (!pick) {
|
|
177
|
+
throw new NotFound("firmware.needed",
|
|
178
|
+
`no ${want.role} build on this machine: ` +
|
|
179
|
+
`meshbench firmware download ${want.role}`, "not_found");
|
|
180
|
+
}
|
|
181
|
+
await this.useForRole(want.role, pick);
|
|
182
|
+
chosen[want.role] = pick;
|
|
183
|
+
}
|
|
184
|
+
return chosen;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Pin every node of a role to one build. */
|
|
188
|
+
async useForRole(role, build) {
|
|
189
|
+
const version = typeof build === "string" ? build : build.version;
|
|
190
|
+
await this._wb.call("firmware.set", { role, version });
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Bring up firmware on every node.
|
|
194
|
+
*
|
|
195
|
+
* Asynchronous, and always has been: it answers with what it has begun, not
|
|
196
|
+
* with what is up. It was synchronous once, and on 155 nodes that froze the
|
|
197
|
+
* window and the socket together for as long as it was left - which read as
|
|
198
|
+
* a crash and was reported as one. Wait with waitStarted. */
|
|
199
|
+
async start() { await this._wb.call("firmware.start"); }
|
|
200
|
+
|
|
201
|
+
/** How far a start has got. */
|
|
202
|
+
async state() { return (await this._wb.call("firmware.state")) || {}; }
|
|
203
|
+
|
|
204
|
+
/** The roles this scenario has nodes for and no build pinned to, with what
|
|
205
|
+
* could be pinned. A run refuses to start until every one is answered. */
|
|
206
|
+
async needed() {
|
|
207
|
+
return ((await this._wb.call("firmware.needed")) || {}).roles || [];
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Wait for every node's firmware to be up. `timeoutMs` is wall clock.
|
|
211
|
+
*
|
|
212
|
+
* `nodes` here is the nodes that run firmware, which is not every node: an
|
|
213
|
+
* SDR observer and an emitter never boot one. It used to be every node, so a
|
|
214
|
+
* fixture holding either reported "56 of 58" until the timeout with no way
|
|
215
|
+
* to see which two.
|
|
216
|
+
*
|
|
217
|
+
* Which is why this names the stragglers rather than counting them. Ten
|
|
218
|
+
* minutes of "56 of 58" tells you nothing; two node names tell you whether a
|
|
219
|
+
* build is missing or a board is wedged. */
|
|
220
|
+
waitStarted(timeoutMs = FIRMWARE_WAIT_MS) {
|
|
221
|
+
let named = 0;
|
|
222
|
+
let last = "";
|
|
223
|
+
return waitFor(async () => {
|
|
224
|
+
const st = await this.state();
|
|
225
|
+
const running = st.running || 0;
|
|
226
|
+
const nodes = st.nodes || 0;
|
|
227
|
+
if (!st.starting && nodes > 0 && running >= nodes) return [true, ""];
|
|
228
|
+
// The names cost a /proc read per node and this polls while firmware is
|
|
229
|
+
// starting, which is the busiest moment there is - every fiftieth of a
|
|
230
|
+
// second is how a diagnostic becomes the fault it was meant to explain,
|
|
231
|
+
// and it timed the socket out. Once every ten seconds is often enough for
|
|
232
|
+
// something a person only reads when the wait fails.
|
|
233
|
+
if (Date.now() - named >= 10_000) {
|
|
234
|
+
named = Date.now();
|
|
235
|
+
last = await this._stragglers();
|
|
236
|
+
}
|
|
237
|
+
return [false, `${running} of ${nodes} running${last}`];
|
|
238
|
+
}, timeoutMs, "firmware to come up");
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async _stragglers() {
|
|
242
|
+
const waiting = (await this._wb.nodeStats())
|
|
243
|
+
.filter((s) => !s.running).map((s) => s.name);
|
|
244
|
+
if (waiting.length === 0) return "";
|
|
245
|
+
let shown = waiting.slice(0, 4).join(", ");
|
|
246
|
+
if (waiting.length > 4) shown += ` and ${waiting.length - 4} more`;
|
|
247
|
+
return `; waiting on ${shown}`;
|
|
248
|
+
}
|
|
249
|
+
}
|
package/lib/launch.mjs
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// Starting a workbench of one's own, and waiting for it to answer.
|
|
2
|
+
//
|
|
3
|
+
// Separate from the Workbench itself because it is process work rather than
|
|
4
|
+
// protocol work: which binary, which address, and how long to wait before
|
|
5
|
+
// deciding it is not coming.
|
|
6
|
+
|
|
7
|
+
import { spawn } from "node:child_process";
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
import os from "node:os";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
|
|
12
|
+
import { MeshbenchError } from "./errors.mjs";
|
|
13
|
+
import {
|
|
14
|
+
BINARY_ENV, Connection, MAX_UNIX_PATH, RENDEZVOUS_ENV,
|
|
15
|
+
} from "./socket.mjs";
|
|
16
|
+
|
|
17
|
+
/** How long to give a workbench to answer before deciding it is not coming.
|
|
18
|
+
* A national fixture takes a while to open and a small one does not. */
|
|
19
|
+
export const START_TIMEOUT_MS = 90_000;
|
|
20
|
+
|
|
21
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
22
|
+
|
|
23
|
+
/** Start `meshbench <command>` and connect to it.
|
|
24
|
+
*
|
|
25
|
+
* Resolves to the connection, the child, and the rendezvous file the child was
|
|
26
|
+
* told to write - which the connection had to be pointed at, because the
|
|
27
|
+
* per-user one names whatever else on this machine last left an address. */
|
|
28
|
+
export async function launch(command, {
|
|
29
|
+
fixture = "", seed = 0, socket = "", binary = "", args = [],
|
|
30
|
+
startTimeoutMs = START_TIMEOUT_MS, callTimeoutMs, stderr = "inherit",
|
|
31
|
+
} = {}) {
|
|
32
|
+
const chosen = address(socket);
|
|
33
|
+
const exe = binary || process.env[BINARY_ENV] || "meshbench";
|
|
34
|
+
const argv = [command, "-control-socket", chosen.address];
|
|
35
|
+
if (fixture) argv.push("-fixture", fixture);
|
|
36
|
+
if (seed) argv.push("-seed", String(seed));
|
|
37
|
+
argv.push(...args);
|
|
38
|
+
|
|
39
|
+
const env = { ...process.env };
|
|
40
|
+
if (chosen.rendezvous) env[RENDEZVOUS_ENV] = chosen.rendezvous;
|
|
41
|
+
let child;
|
|
42
|
+
try {
|
|
43
|
+
child = spawn(exe, argv, { env, stdio: ["ignore", "inherit", stderr] });
|
|
44
|
+
} catch (e) {
|
|
45
|
+
throw new MeshbenchError(`could not start ${exe}: ${e.message}`);
|
|
46
|
+
}
|
|
47
|
+
let exited = null;
|
|
48
|
+
child.on("error", (e) => { exited = e.message; });
|
|
49
|
+
child.on("exit", (code) => { exited ??= `exited with ${code}`; });
|
|
50
|
+
|
|
51
|
+
// Wait for the socket rather than for a fixed moment: a sleep long enough for
|
|
52
|
+
// a national fixture is wasted on every run of a small one.
|
|
53
|
+
const deadline = Date.now() + startTimeoutMs;
|
|
54
|
+
for (;;) {
|
|
55
|
+
if (exited) {
|
|
56
|
+
throw new MeshbenchError(
|
|
57
|
+
`${exe} ${command} ${exited} before answering at ${chosen.address}`);
|
|
58
|
+
}
|
|
59
|
+
try {
|
|
60
|
+
const conn = await Connection.open({
|
|
61
|
+
address: chosen.address, rendezvous: chosen.rendezvous,
|
|
62
|
+
connectTimeoutMs: 1000, callTimeoutMs,
|
|
63
|
+
});
|
|
64
|
+
return { conn, child, rendezvous: chosen.rendezvous };
|
|
65
|
+
} catch (e) {
|
|
66
|
+
if (Date.now() > deadline) {
|
|
67
|
+
child.kill("SIGKILL");
|
|
68
|
+
throw new MeshbenchError(
|
|
69
|
+
`${exe} ${command} did not answer at ${chosen.address} within ` +
|
|
70
|
+
`${Math.round(startTimeoutMs / 1000)}s: ${e.message}`);
|
|
71
|
+
}
|
|
72
|
+
await sleep(50);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** An address of its own unless one was named, so launching two of these does
|
|
78
|
+
* not have them fight over the per-user default.
|
|
79
|
+
*
|
|
80
|
+
* Two reasons that path may not do. Windows has no unix socket a Node client
|
|
81
|
+
* can reach, and a temporary directory on macOS is long enough on its own to
|
|
82
|
+
* exceed sun_path. Either way, loopback - with a rendezvous file of its own
|
|
83
|
+
* too, or two sessions would overwrite each other's port and token in the
|
|
84
|
+
* per-user one. */
|
|
85
|
+
function address(named) {
|
|
86
|
+
if (named) return { address: named, rendezvous: "" };
|
|
87
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "meshbench"));
|
|
88
|
+
const sock = path.join(dir, "control.sock");
|
|
89
|
+
if (process.platform === "win32" || sock.length > MAX_UNIX_PATH) {
|
|
90
|
+
return { address: "tcp", rendezvous: path.join(dir, "control.json") };
|
|
91
|
+
}
|
|
92
|
+
return { address: sock, rendezvous: "" };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Stop a workbench this client started.
|
|
96
|
+
*
|
|
97
|
+
* An interrupt asks the run to stop its firmware on the way out, which on
|
|
98
|
+
* fifty-eight emulated nodes is not instant. Windows has no SIGINT to send a
|
|
99
|
+
* process that is not sharing a console, so it is killed there and whatever
|
|
100
|
+
* the run was holding is the operating system's problem. */
|
|
101
|
+
export async function stop(child, graceMs = 20_000) {
|
|
102
|
+
if (child.exitCode !== null || child.signalCode !== null) return;
|
|
103
|
+
child.kill(process.platform === "win32" ? "SIGKILL" : "SIGINT");
|
|
104
|
+
const deadline = Date.now() + graceMs;
|
|
105
|
+
while (child.exitCode === null && child.signalCode === null) {
|
|
106
|
+
if (Date.now() > deadline) {
|
|
107
|
+
child.kill("SIGKILL");
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
await sleep(50);
|
|
111
|
+
}
|
|
112
|
+
}
|
package/lib/live.mjs
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// Bringing a real deployment in from a live feed.
|
|
2
|
+
//
|
|
3
|
+
// Four steps in a fixed order, and every one of them has been skipped by
|
|
4
|
+
// somebody at least once. The two that get missed are the last two, and missing
|
|
5
|
+
// them does not fail: the mesh comes up with regions inferred but never
|
|
6
|
+
// applied, which transmits everything, relays nothing, and reports no error at
|
|
7
|
+
// all. It reads as bad RF.
|
|
8
|
+
//
|
|
9
|
+
// So the steps are here individually, because sometimes you want to look at a
|
|
10
|
+
// preview before committing - and `pull` runs all four, because the ordinary
|
|
11
|
+
// case is wanting the whole deployment and the ordinary mistake is stopping
|
|
12
|
+
// early.
|
|
13
|
+
|
|
14
|
+
import { MeshbenchError } from "./errors.mjs";
|
|
15
|
+
import { Strategy } from "./sets.mjs";
|
|
16
|
+
import { ImportPreview } from "./values.mjs";
|
|
17
|
+
import { JOB_WAIT_MS } from "./wait.mjs";
|
|
18
|
+
|
|
19
|
+
/** How far back to read traffic when working out what each node holds.
|
|
20
|
+
*
|
|
21
|
+
* A week, because that is what it takes for the quiet regions to say anything
|
|
22
|
+
* at all: on ScotMesh a small region is about sixty packets in seven days, and
|
|
23
|
+
* a shorter window drops it entirely rather than reporting it as thin. */
|
|
24
|
+
export const DEFAULT_WINDOW_HOURS = 7 * 24;
|
|
25
|
+
|
|
26
|
+
/** A live feed, and the deployment it describes. Live in both senses. */
|
|
27
|
+
export class Live {
|
|
28
|
+
constructor(wb) { this._wb = wb; }
|
|
29
|
+
|
|
30
|
+
/** Fetch, commit, read the traffic, and apply what it implies.
|
|
31
|
+
*
|
|
32
|
+
* The whole chain, in the order that works. `windowHours` is how far back
|
|
33
|
+
* into the feed's history to read - the mesh's own past, not your patience;
|
|
34
|
+
* `waitMs` is yours.
|
|
35
|
+
*
|
|
36
|
+
* Returns what the fetch found. Link measurement is still running when this
|
|
37
|
+
* returns on anything but a small mesh, so follow it with `wb.waitIdle()`
|
|
38
|
+
* before starting a run. */
|
|
39
|
+
async pull(url, { strategy = Strategy.REPLACE,
|
|
40
|
+
windowHours = DEFAULT_WINDOW_HOURS, waitMs = JOB_WAIT_MS } = {}) {
|
|
41
|
+
const preview = await this.fetch(url);
|
|
42
|
+
if (preview.nodes === 0) {
|
|
43
|
+
throw new MeshbenchError(
|
|
44
|
+
`${url} described ${preview.records} nodes, none usable`);
|
|
45
|
+
}
|
|
46
|
+
await this.commit(strategy);
|
|
47
|
+
await this.infer({ windowHours, waitMs });
|
|
48
|
+
await this.applyRegions();
|
|
49
|
+
return preview;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Point at a feed without reading it, and say how the URL was tidied.
|
|
53
|
+
*
|
|
54
|
+
* A method rather than a property, because a property implies something to
|
|
55
|
+
* read back and the session offers no way to ask what its source currently
|
|
56
|
+
* is. One that answered from a value this object happened to remember would
|
|
57
|
+
* be right until anything else set it. */
|
|
58
|
+
async setSource(url) {
|
|
59
|
+
return ((await this._wb.call("import.set_source", { url })) || {}).url || url;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Read the deployment and say what would change, changing nothing. */
|
|
63
|
+
async fetch(url = "") {
|
|
64
|
+
if (url) await this.setSource(url);
|
|
65
|
+
return new ImportPreview((await this._wb.call("import.fetch")) || {});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Apply the fetched nodes to the scenario, and say how many it now holds.
|
|
69
|
+
*
|
|
70
|
+
* "replace-all" is what the shipped fixtures were built with; "add" keeps
|
|
71
|
+
* what is already here and skips names that clash.
|
|
72
|
+
*
|
|
73
|
+
* Measuring the links afterwards is a job rather than part of this call - 676
|
|
74
|
+
* nodes is 228,000 terrain paths over real ground - so this returns while
|
|
75
|
+
* that is still running. */
|
|
76
|
+
async commit(strategy = Strategy.REPLACE) {
|
|
77
|
+
return ((await this._wb.call("import.commit", { strategy })) || {}).nodes || 0;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Read the feed's recent traffic to work out what each node holds.
|
|
81
|
+
*
|
|
82
|
+
* This is the step that decides whether anything relays. A node whose regions
|
|
83
|
+
* are unknown forwards nothing, and nothing says so.
|
|
84
|
+
*
|
|
85
|
+
* `windowHours` is the feed's own past; `waitMs` is how long you will sit
|
|
86
|
+
* here for it. A week of ScotMesh is around 150,000 packets and several
|
|
87
|
+
* minutes of paging. */
|
|
88
|
+
async infer({ windowHours = DEFAULT_WINDOW_HOURS, waitMs = JOB_WAIT_MS } = {}) {
|
|
89
|
+
if (!(windowHours > 0)) {
|
|
90
|
+
throw new MeshbenchError("infer() needs a window, in hours of the feed's past");
|
|
91
|
+
}
|
|
92
|
+
await this._wb.call("infer.run", { hours: windowHours });
|
|
93
|
+
await this._wb.job("infer").wait(waitMs);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Put the inferred regions onto the nodes, and say how many took one.
|
|
97
|
+
*
|
|
98
|
+
* The forgotten step. Everything above can succeed and the mesh still be
|
|
99
|
+
* silent until this runs. */
|
|
100
|
+
async applyRegions() {
|
|
101
|
+
return ((await this._wb.call("infer.apply")) || {}).applied || 0;
|
|
102
|
+
}
|
|
103
|
+
}
|