@tsplc/debug 0.2.17 → 0.2.18

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/src/login.ts CHANGED
@@ -1,117 +1,35 @@
1
1
  import { setTimeout as delay } from "node:timers/promises";
2
- import type { Connection, TargetAdapter, TargetRead, TargetSample } from "./adapter.ts";
2
+ import type {
3
+ Connection,
4
+ TargetAdapter,
5
+ TargetRead,
6
+ TargetSample,
7
+ } from "./adapter.ts";
8
+ import { errorMessage } from "./errors.ts";
9
+ import { parseLoginValue } from "./login-value.ts";
3
10
  import type { DebugType } from "./manifest.ts";
4
11
 
5
12
  /** Why the login can't read now; `state` is what the debugger shows meanwhile. */
6
13
  export class LoginUnavailable extends Error {
7
- constructor(message: string, options?: ErrorOptions & { state?: "connecting" | "unavailable" }) {
8
- super(message, options);
9
- this.state = options?.state ?? "unavailable";
10
- }
14
+ constructor(
15
+ message: string,
16
+ options?: ErrorOptions & { state?: "connecting" | "unavailable" },
17
+ ) {
18
+ super(message, options);
19
+ this.state = options?.state ?? "unavailable";
20
+ }
11
21
 
12
- readonly state: "connecting" | "unavailable";
22
+ readonly state: "connecting" | "unavailable";
13
23
  }
14
24
 
15
- export const noLogin = "Live values need an engineering login: run `tsplc login`.";
25
+ export const noLogin =
26
+ "Live values need an engineering login: run `tsplc login`.";
16
27
 
17
28
  /** How long to wait between attempts while there is no login to read through. */
18
29
  const retryMs = 5000;
19
30
 
20
- const integerTypes = new Set([
21
- ...["SINT", "INT", "DINT", "LINT", "USINT", "UINT", "UDINT", "ULINT"],
22
- ...["BYTE", "WORD", "DWORD", "LWORD"],
23
- ]);
24
- const timeUnits: Record<string, number> = {
25
- d: 86_400_000,
26
- h: 3_600_000,
27
- m: 60_000,
28
- s: 1000,
29
- ms: 1,
30
- us: 0.001,
31
- ns: 0.000_001,
32
- };
33
-
34
- const typeName = (type: DebugType) => (type.kind === "scalar" ? type.iec : type.kind);
35
-
36
- const message = (error: unknown) => (error instanceof Error ? error.message : String(error));
37
-
38
- /** `16#FF`, `2#1010`, `-42`, with IEC's digit separators; beyond doubles' exact range, a string. */
39
- const integer = (text: string) => {
40
- const radix = /^(2|8|16)#([0-9a-f_]+)$/i.exec(text);
41
- const digits = radix ? radix[2]!.replaceAll("_", "") : text.replaceAll("_", "");
42
- if (!radix && !/^[+-]?\d+$/.test(digits)) return undefined;
43
- const value = BigInt(radix ? `0${{ 2: "b", 8: "o", 16: "x" }[radix[1]!]}${digits}` : digits);
44
- return Number.isSafeInteger(Number(value)) ? Number(value) : String(value);
45
- };
46
-
47
- /** `T#1h2m3s4ms`, `TIME#-1.5s`, `LTIME#5us`, in milliseconds. */
48
- const duration = (text: string) => {
49
- const body = /^L?T(?:IME)?#(-?)(.+)$/i.exec(text);
50
- if (!body) return undefined;
51
- const parts = [...body[2]!.replaceAll("_", "").matchAll(/(\d+(?:\.\d+)?)(ms|us|ns|d|h|m|s)/gi)];
52
- if (parts.map(([part]) => part).join("") !== body[2]!.replaceAll("_", "")) return undefined;
53
- const ms = parts.reduce(
54
- (total, [, amount, unit]) => total + Number(amount) * timeUnits[unit!.toLowerCase()]!,
55
- 0,
56
- );
57
- return body[1] ? -ms : ms;
58
- };
59
-
60
- /** `'it$'s'` or `"wide"`, with IEC's `$` escapes. */
61
- const quoted = (text: string) => {
62
- const body = /^(['"])(.*)\1$/s.exec(text);
63
- if (!body) return undefined;
64
- return body[2]!.replace(/\$([0-9a-f]{2}|[$'"lnprt])/gi, (_, code: string) => {
65
- if (code.length === 2) return String.fromCharCode(parseInt(code, 16));
66
- const named: Record<string, string> = { l: "\n", n: "\n", p: "\f", r: "\r", t: "\t" };
67
- return named[code.toLowerCase()] ?? code;
68
- });
69
- };
70
-
71
- /**
72
- * A value as `read_value` returns it, in the form the debugger decodes: BOOL as a boolean,
73
- * numbers and TIME (in ms) as numbers, enums as their member's number (or its name when the
74
- * manifest doesn't list it), STRING unquoted, and arrays read whole as arrays. Undefined when the
75
- * text doesn't fit the type.
76
- */
77
- export const parseLoginValue = (type: DebugType, text: string): unknown => {
78
- const trimmed = text.trim();
79
- // Typed literals such as `INT#5`; TIME's own `T#` prefix is kept.
80
- const untyped = trimmed.replace(/^(?!L?T(?:IME)?#)[a-z_]+#(?=.)/i, "");
81
- switch (type.kind) {
82
- case "enum": {
83
- const number = integer(untyped);
84
- if (typeof number === "number") return number;
85
- const named = /^(?:\w+\.)*(\w+)(?:\s*\((-?\d+)\))?$/.exec(untyped);
86
- if (!named) return undefined;
87
- return type.members[named[1]!] ?? (named[2] === undefined ? named[1] : Number(named[2]));
88
- }
89
- case "array": {
90
- const items = /^\[?(.*?)\]?$/s.exec(untyped)![1]!.trim();
91
- if (!items) return [];
92
- // Only raw assembly bytes are read whole; their elements are scalars named by IEC type.
93
- const element = { kind: "scalar", iec: type.element } as const;
94
- const values = items.split(/\s*,\s*/).map((item) => parseLoginValue(element, item));
95
- return values.length === type.length && values.every((value) => value !== undefined)
96
- ? values
97
- : undefined;
98
- }
99
- case "record":
100
- return undefined;
101
- case "scalar":
102
- break;
103
- }
104
- const iec = type.iec.toUpperCase();
105
- if (iec === "BOOL") return { TRUE: true, FALSE: false, 1: true, 0: false }[untyped.toUpperCase()];
106
- if (integerTypes.has(iec)) return integer(untyped);
107
- if (iec === "REAL" || iec === "LREAL") {
108
- const number = Number(untyped);
109
- return untyped !== "" && !Number.isNaN(number) ? number : undefined;
110
- }
111
- if (iec === "TIME" || iec === "LTIME") return duration(trimmed);
112
- if (/^W?STRING\b/.test(iec)) return quoted(trimmed) ?? trimmed;
113
- return trimmed;
114
- };
31
+ const typeName = (type: DebugType) =>
32
+ type.kind === "scalar" ? type.iec : type.kind;
115
33
 
116
34
  /**
117
35
  * Reads through an engineering login that answers `read(expressions)` with CODESYS's own text
@@ -123,162 +41,224 @@ export const parseLoginValue = (type: DebugType, text: string): unknown => {
123
41
  * expressions that fail are found, and those are reported bad until the next login.
124
42
  */
125
43
  export const createLoginAdapter = (options: {
126
- read: (expressions: string[]) => Promise<Record<string, string>>;
127
- /** A target that always reads, such as the build id. */
128
- probe: string;
129
- endpoint: string;
130
- samplingMs?: number;
44
+ read: (expressions: string[]) => Promise<Record<string, string>>;
45
+ /** A target that always reads, such as the build id. */
46
+ probe: string;
47
+ endpoint: string;
48
+ samplingMs?: number;
131
49
  }): TargetAdapter => {
132
- const samplingMs = options.samplingMs ?? 1000;
133
- let connection: Connection = { state: "connecting", since: new Date().toISOString() };
134
- const connectionListeners = new Set<(connection: Connection) => void>();
135
- const subscriptions = new Set<{
136
- targets: readonly TargetRead[];
137
- listener: (target: string, sample: TargetSample) => void;
138
- intervalMs: number;
139
- due: number;
140
- }>();
141
- /** Expressions the controller refused, with its reason, until the login is lost. */
142
- const rejected = new Map<string, string>();
143
- const controller = new AbortController();
50
+ const samplingMs = options.samplingMs ?? 1000;
51
+ let connection: Connection = {
52
+ state: "connecting",
53
+ since: new Date().toISOString(),
54
+ };
55
+ const connectionListeners = new Set<(connection: Connection) => void>();
56
+ const subscriptions = new Set<{
57
+ targets: readonly TargetRead[];
58
+ listener: (target: string, sample: TargetSample) => void;
59
+ intervalMs: number;
60
+ due: number;
61
+ }>();
62
+ /** Expressions the controller refused, with its reason, until the login is lost. */
63
+ const rejected = new Map<string, string>();
64
+ const controller = new AbortController();
65
+
66
+ const setConnection = (state: Connection["state"], error?: string) => {
67
+ if (controller.signal.aborted) {
68
+ return;
69
+ }
70
+ if (connection.state === state && connection.error === error) {
71
+ return;
72
+ }
73
+ if (state !== "connected") {
74
+ rejected.clear();
75
+ }
76
+ connection = {
77
+ state,
78
+ since: new Date().toISOString(),
79
+ ...(error && { error }),
80
+ };
81
+ for (const listener of connectionListeners) {
82
+ listener(connection);
83
+ }
84
+ };
85
+
86
+ const split = async (
87
+ expressions: string[],
88
+ ): Promise<Record<string, string>> => {
89
+ try {
90
+ return await options.read(expressions);
91
+ } catch (error) {
92
+ if (error instanceof LoginUnavailable) {
93
+ throw error;
94
+ }
95
+ if (expressions.length === 1) {
96
+ rejected.set(expressions[0]!, errorMessage(error));
97
+ return {};
98
+ }
99
+ const half = Math.ceil(expressions.length / 2);
100
+ return {
101
+ ...(await split(expressions.slice(0, half))),
102
+ ...(await split(expressions.slice(half))),
103
+ };
104
+ }
105
+ };
106
+
107
+ const readText = async (expressions: string[]) => {
108
+ try {
109
+ return await options.read(expressions);
110
+ } catch (error) {
111
+ if (error instanceof LoginUnavailable) {
112
+ throw error;
113
+ }
114
+ // One expression the controller can't read fails the whole read; a lost login fails the probe too.
115
+ await options.read([options.probe]);
116
+ return split(expressions);
117
+ }
118
+ };
144
119
 
145
- const setConnection = (state: Connection["state"], error?: string) => {
146
- if (controller.signal.aborted) return;
147
- if (connection.state === state && connection.error === error) return;
148
- if (state !== "connected") rejected.clear();
149
- connection = { state, since: new Date().toISOString(), ...(error && { error }) };
150
- for (const listener of connectionListeners) listener(connection);
151
- };
120
+ /** The login's text for each expression, or why it can't read; keeps `connection` current. */
121
+ const readTexts = async (
122
+ expressions: string[],
123
+ ): Promise<{ texts: Record<string, string> } | { error: string }> => {
124
+ const overdue = setTimeout(
125
+ () => setConnection("lost", "The engineering login stopped answering"),
126
+ 3 * samplingMs,
127
+ );
128
+ try {
129
+ const texts = expressions.length ? await readText(expressions) : {};
130
+ setConnection("connected");
131
+ return { texts };
132
+ } catch (error) {
133
+ setConnection(
134
+ error instanceof LoginUnavailable ? error.state : "lost",
135
+ errorMessage(error),
136
+ );
137
+ return { error: errorMessage(error) };
138
+ } finally {
139
+ clearTimeout(overdue);
140
+ }
141
+ };
152
142
 
153
- const split = async (expressions: string[]): Promise<Record<string, string>> => {
154
- try {
155
- return await options.read(expressions);
156
- } catch (error) {
157
- if (error instanceof LoginUnavailable) throw error;
158
- if (expressions.length === 1) {
159
- rejected.set(expressions[0]!, message(error));
160
- return {};
161
- }
162
- const half = Math.ceil(expressions.length / 2);
163
- return {
164
- ...(await split(expressions.slice(0, half))),
165
- ...(await split(expressions.slice(half))),
166
- };
167
- }
168
- };
143
+ /** A target's value from the login's text, or bad with the reason it has none. */
144
+ const parsedSample = (
145
+ { target, type }: TargetRead,
146
+ text: string | undefined,
147
+ receivedAt: string,
148
+ ): TargetSample => {
149
+ const value = text === undefined ? undefined : parseLoginValue(type, text);
150
+ if (value !== undefined) {
151
+ return { value, quality: "good", status: "Good", receivedAt };
152
+ }
153
+ const reason =
154
+ text === undefined
155
+ ? "Not returned by the login"
156
+ : `Not a ${typeName(type)}`;
157
+ return {
158
+ value: text ?? null,
159
+ quality: "bad",
160
+ status: rejected.get(target) ?? reason,
161
+ receivedAt,
162
+ };
163
+ };
169
164
 
170
- const readText = async (expressions: string[]) => {
171
- try {
172
- return await options.read(expressions);
173
- } catch (error) {
174
- if (error instanceof LoginUnavailable) throw error;
175
- // One expression the controller can't read fails the whole read; a lost login fails the probe too.
176
- await options.read([options.probe]);
177
- return split(expressions);
178
- }
179
- };
165
+ /** Never fails: while the login can't read, every target is unavailable. */
166
+ const read = async (targets: readonly TargetRead[]) => {
167
+ const unique = [
168
+ ...new Map(targets.map((target) => [target.target, target])).values(),
169
+ ];
170
+ const result = await readTexts(
171
+ unique
172
+ .map(({ target }) => target)
173
+ .filter((target) => !rejected.has(target)),
174
+ );
175
+ const receivedAt = new Date().toISOString();
176
+ if ("error" in result) {
177
+ return new Map<string, TargetSample>(
178
+ unique.map(({ target }) => [
179
+ target,
180
+ {
181
+ value: null,
182
+ quality: "unavailable",
183
+ status: result.error,
184
+ receivedAt,
185
+ },
186
+ ]),
187
+ );
188
+ }
189
+ return new Map(
190
+ unique.map((read) => [
191
+ read.target,
192
+ parsedSample(read, result.texts[read.target], receivedAt),
193
+ ]),
194
+ );
195
+ };
180
196
 
181
- /** Never fails: while the login can't read, every target is unavailable. */
182
- const read = async (targets: readonly TargetRead[]) => {
183
- const unique = [...new Map(targets.map((target) => [target.target, target])).values()];
184
- const expressions = unique
185
- .map(({ target }) => target)
186
- .filter((target) => !rejected.has(target));
187
- const samples = new Map<string, TargetSample>();
188
- let values: Record<string, string>;
189
- const overdue = setTimeout(
190
- () => setConnection("lost", "The engineering login stopped answering"),
191
- 3 * samplingMs,
192
- );
193
- try {
194
- values = expressions.length ? await readText(expressions) : {};
195
- setConnection("connected");
196
- } catch (error) {
197
- setConnection(error instanceof LoginUnavailable ? error.state : "lost", message(error));
198
- const receivedAt = new Date().toISOString();
199
- for (const { target } of unique)
200
- samples.set(target, {
201
- value: null,
202
- quality: "unavailable",
203
- status: message(error),
204
- receivedAt,
205
- });
206
- return samples;
207
- } finally {
208
- clearTimeout(overdue);
209
- }
210
- const receivedAt = new Date().toISOString();
211
- for (const { target, type } of unique) {
212
- const text = values[target];
213
- const refused = rejected.get(target);
214
- const value = text === undefined ? undefined : parseLoginValue(type, text);
215
- samples.set(
216
- target,
217
- value !== undefined
218
- ? { value, quality: "good", status: "Good", receivedAt }
219
- : {
220
- value: text ?? null,
221
- quality: "bad",
222
- status:
223
- refused ??
224
- (text === undefined ? "Not returned by the login" : `Not a ${typeName(type)}`),
225
- receivedAt,
226
- },
227
- );
228
- }
229
- return samples;
230
- };
197
+ /** Until the next poll: the rest of the interval, as long again after an overrun, longer without a login. */
198
+ const pauseAfter = (tookMs: number) => {
199
+ if (connection.state !== "connected" && connection.state !== "connecting") {
200
+ return retryMs;
201
+ }
202
+ return tookMs > samplingMs ? tookMs : samplingMs - tookMs;
203
+ };
231
204
 
232
- const poll = async () => {
233
- while (!controller.signal.aborted) {
234
- const started = Date.now();
235
- // Timers fire a little early or late; a subscription due within half an interval is read now.
236
- const due = [...subscriptions].filter(({ due }) => due <= started + samplingMs / 2);
237
- if (due.length) {
238
- for (const subscription of due) subscription.due = started + subscription.intervalMs;
239
- // Closing the debugger must not wait for an engineering worker that stopped answering.
240
- const stopped = Promise.withResolvers<void>();
241
- const stop = () => stopped.resolve();
242
- controller.signal.addEventListener("abort", stop, { once: true });
243
- const samples = await Promise.race([
244
- read(due.flatMap(({ targets }) => targets)),
245
- stopped.promise,
246
- ]).finally(() => controller.signal.removeEventListener("abort", stop));
247
- if (!samples || controller.signal.aborted) break;
248
- for (const subscription of due)
249
- if (subscriptions.has(subscription))
250
- for (const { target } of subscription.targets)
251
- subscription.listener(target, samples.get(target)!);
252
- }
253
- const took = Date.now() - started;
254
- const waiting = connection.state !== "connected" && connection.state !== "connecting";
255
- await delay(waiting ? retryMs : took > samplingMs ? took : samplingMs - took, undefined, {
256
- signal: controller.signal,
257
- }).catch(() => undefined);
258
- }
259
- };
260
- const running = poll();
205
+ const poll = async () => {
206
+ while (!controller.signal.aborted) {
207
+ const started = Date.now();
208
+ // Timers fire a little early or late; a subscription due within half an interval is read now.
209
+ const due = [...subscriptions].filter(
210
+ ({ due }) => due <= started + samplingMs / 2,
211
+ );
212
+ if (due.length) {
213
+ for (const subscription of due) {
214
+ subscription.due = started + subscription.intervalMs;
215
+ }
216
+ // Closing the debugger must not wait for an engineering worker that stopped answering.
217
+ const stopped = Promise.withResolvers<void>();
218
+ const stop = () => stopped.resolve();
219
+ controller.signal.addEventListener("abort", stop, { once: true });
220
+ const samples = await Promise.race([
221
+ read(due.flatMap(({ targets }) => targets)),
222
+ stopped.promise,
223
+ ]).finally(() => controller.signal.removeEventListener("abort", stop));
224
+ if (!samples || controller.signal.aborted) {
225
+ break;
226
+ }
227
+ for (const subscription of due) {
228
+ if (subscriptions.has(subscription)) {
229
+ for (const { target } of subscription.targets) {
230
+ subscription.listener(target, samples.get(target)!);
231
+ }
232
+ }
233
+ }
234
+ }
235
+ await delay(pauseAfter(Date.now() - started), undefined, {
236
+ signal: controller.signal,
237
+ }).catch(() => undefined);
238
+ }
239
+ };
240
+ const running = poll();
261
241
 
262
- return {
263
- method: "engineering login",
264
- endpoint: options.endpoint,
265
- samplingMs,
266
- connection: () => connection,
267
- onConnection: (listener) => {
268
- connectionListeners.add(listener);
269
- return () => connectionListeners.delete(listener);
270
- },
271
- read,
272
- subscribe: (targets, listener, intervalMs = samplingMs) => {
273
- const subscription = { targets, listener, intervalMs, due: 0 };
274
- subscriptions.add(subscription);
275
- return () => subscriptions.delete(subscription);
276
- },
277
- close: async () => {
278
- controller.abort();
279
- subscriptions.clear();
280
- connectionListeners.clear();
281
- await running;
282
- },
283
- };
242
+ return {
243
+ method: "engineering login",
244
+ endpoint: options.endpoint,
245
+ samplingMs,
246
+ connection: () => connection,
247
+ onConnection: (listener) => {
248
+ connectionListeners.add(listener);
249
+ return () => connectionListeners.delete(listener);
250
+ },
251
+ read,
252
+ subscribe: (targets, listener, intervalMs = samplingMs) => {
253
+ const subscription = { targets, listener, intervalMs, due: 0 };
254
+ subscriptions.add(subscription);
255
+ return () => subscriptions.delete(subscription);
256
+ },
257
+ close: async () => {
258
+ controller.abort();
259
+ subscriptions.clear();
260
+ connectionListeners.clear();
261
+ await running;
262
+ },
263
+ };
284
264
  };