@tikoci/quickchr 0.4.6 → 0.4.7

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.
@@ -27,6 +27,31 @@ const DEFAULT_TIMEOUT_MS = 30_000;
27
27
  /** Interval for polling the buffer in waitFor(). */
28
28
  const POLL_INTERVAL_MS = 250;
29
29
 
30
+ /**
31
+ * What one *fresh* serial login costs, for callers that must budget for it.
32
+ *
33
+ * A login is not a round-trip — it is dominated by RouterOS, not by us.
34
+ * Measured 2026-07-31 on 7.23.2 / x86 / HVF with a raw socket and timestamped
35
+ * receives (tikoci/quickchr#69, bite B10 of #110):
36
+ *
37
+ * banner → `Login:` 10–25 ms
38
+ * username → `Password:` 54–56 ms
39
+ * password → license `[Y/n]:` **10,184 ms** ← the whole cost
40
+ * full fresh `consoleExec()` 11,364 / 11,360 ms
41
+ * `consoleExec()` on an already-open session 306 ms
42
+ *
43
+ * So a budget sized on the ~0.3 s round-trip figure is ~40× short of what the
44
+ * first call needs, and a caller that divides such a budget across credential
45
+ * candidates can never complete even one. That is exactly what made
46
+ * `captureGuestSnapshot()` report `consoleReachable: false` against provably
47
+ * healthy guests. Anything that logs in must budget from this constant, not
48
+ * from the round-trip cost.
49
+ *
50
+ * 15 s is the measured 11.4 s plus ~30% margin; TCG and cross-arch guests are
51
+ * slower still, which is why callers stack it rather than treat it as a cap.
52
+ */
53
+ export const CONSOLE_LOGIN_COST_MS = 15_000;
54
+
30
55
  /** Pre-prompt pattern: the "] > " suffix is version-proof since it
31
56
  * covers [admin@MikroTik] > , [admin@CHR] > , and any custom identity. */
32
57
  const PROMPT_PATTERN = "] > ";
@@ -120,50 +145,130 @@ async function waitFor(
120
145
  }
121
146
 
122
147
  /**
123
- * Wait for the LAST occurrence of `pattern` at or after `searchFrom` in the
124
- * buffer, determined by buffer stability.
148
+ * Ceiling on the quiet period the fallback prompt terminator waits for.
149
+ *
150
+ * This is no longer the normal way a command reply ends — {@link makeSentinel}
151
+ * is — so it is sized for safety rather than speed, and does not need to scale
152
+ * with the accelerator. The old flat 150 ms *was* the normal path, and that is
153
+ * exactly what let a prompt **redraw** terminate a slow large reply (#109): the
154
+ * two biggest guest-snapshot queries came back empty on a loaded TCG guest while
155
+ * every small one succeeded.
156
+ */
157
+ const FALLBACK_PROMPT_STABLE_MS = 2_000;
158
+
159
+ /** Floor, so a very small `timeoutMs` cannot reduce the window to nothing. */
160
+ const MIN_PROMPT_STABLE_MS = 250;
161
+
162
+ /**
163
+ * The quiet period to require before the fallback prompt may terminate a reply.
164
+ *
165
+ * Derived from the caller's budget rather than fixed: a caller passing less than
166
+ * ~2 s would otherwise never reach the fallback at all, and an unframed but
167
+ * *complete* reply would surface as a `BOOT_TIMEOUT` instead of `framed: false`.
168
+ * A small fraction leaves the rest of the budget for the reply itself. The
169
+ * window is only a secondary guard now — {@link isTrailingPrompt} already
170
+ * rejects a redraw structurally, whatever the timing.
171
+ */
172
+ function promptStableWindow(timeoutMs: number): number {
173
+ return Math.min(FALLBACK_PROMPT_STABLE_MS, Math.max(MIN_PROMPT_STABLE_MS, Math.floor(timeoutMs / 8)));
174
+ }
175
+
176
+ /** True when `pattern` at `idx` is the last meaningful thing in the buffer.
177
+ *
178
+ * The redraw that broke #109 is "prompt + repainted command", so the redraw
179
+ * prompt always has the command text after it. Requiring the prompt to sit at
180
+ * the end of the buffer therefore rejects a redraw outright, independently of
181
+ * any timing window. Trailing ANSI and whitespace are ignored — RouterOS paints
182
+ * the prompt with color codes and terminal queries around it. */
183
+ function isTrailingPrompt(buffer: string, idx: number, pattern: string): boolean {
184
+ return stripAnsi(buffer.slice(idx + pattern.length)).trim() === "";
185
+ }
186
+
187
+ /** How a command's reply ended. */
188
+ type ReplyEnd =
189
+ /** The sentinel arrived — the reply is complete by construction. */
190
+ | { kind: "sentinel"; index: number }
191
+ /** No sentinel; a trailing, stable prompt terminated it instead. */
192
+ | { kind: "prompt"; index: number };
193
+
194
+ /**
195
+ * Build the end-of-reply sentinel.
196
+ *
197
+ * The marker is assembled **in the guest** from three string literals, so the
198
+ * echoed command line contains the expression but never the contiguous marker
199
+ * text. That is what makes "the marker appeared" mean "the payload is complete"
200
+ * rather than "the command was echoed".
201
+ *
202
+ * It is sent as a **separate console line**, not chained with `;`. Measured on
203
+ * 7.21.5: `/bogus print; :put ("QC" . "END" . "e1")` prints the syntax error and
204
+ * **never runs the second statement**, and the same holds for a runtime error
205
+ * (`/ip/address add address=nonsense; …`). A `;` chain would therefore lose the
206
+ * terminator on exactly the replies that carry an error message. Two `\r`-
207
+ * terminated lines are two separate console inputs, so the second one still runs.
208
+ */
209
+ function makeSentinel(): { command: string; marker: string; nonce: string } {
210
+ const nonce = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
211
+ return {
212
+ command: `:put ("QCHR" . "${nonce}" . "END")`,
213
+ marker: `QCHR${nonce}END`,
214
+ nonce,
215
+ };
216
+ }
217
+
218
+ /**
219
+ * Wait for a command's reply to end — by sentinel, or failing that by a prompt
220
+ * that is both trailing and stable.
221
+ *
222
+ * Both terminators are watched in **one** loop on purpose. Waiting out the
223
+ * sentinel first and only then starting a prompt wait would spend the entire
224
+ * budget before the fallback got a chance, turning every unframed reply into a
225
+ * timeout instead of a slower success.
125
226
  *
126
- * RouterOS's VT100 terminal redraws the prompt+command line during execution,
127
- * so the first "] > " after the command is just the redraw, not the output prompt.
128
- * We wait until no new data arrives for `stableMs` milliseconds, then return
129
- * the position of the LAST occurrence of `pattern`.
227
+ * Returns null if neither terminator arrives in time.
130
228
  */
131
- async function waitForFinalPrompt(
229
+ async function waitForReplyEnd(
132
230
  session: ConsoleSessionState,
231
+ marker: string,
133
232
  pattern: string,
134
233
  searchFrom: number,
135
234
  timeoutMs: number,
136
- stableMs = 150,
137
- ): Promise<number> {
235
+ stableMs = promptStableWindow(timeoutMs),
236
+ ): Promise<ReplyEnd | null> {
138
237
  const FAST_POLL = 50;
139
238
  const deadline = Date.now() + timeoutMs;
140
239
  let lastGrowthAt = Date.now();
141
240
  let lastLen = session.buffer.length;
142
- let lastPromptAt = -1;
143
241
 
144
- while (Date.now() < deadline) {
242
+ const trailingPrompt = (): number => {
243
+ const candidate = session.buffer.lastIndexOf(pattern);
244
+ if (candidate < searchFrom) return -1;
245
+ return isTrailingPrompt(session.buffer, candidate, pattern) ? candidate : -1;
246
+ };
247
+
248
+ for (;;) {
249
+ const sentinelAt = session.buffer.indexOf(marker, searchFrom);
250
+ if (sentinelAt >= 0) {
251
+ session.matchOffset = sentinelAt + marker.length;
252
+ return { kind: "sentinel", index: sentinelAt };
253
+ }
254
+
145
255
  const currentLen = session.buffer.length;
146
256
  if (currentLen > lastLen) {
147
257
  lastLen = currentLen;
148
258
  lastGrowthAt = Date.now();
149
- const candidate = session.buffer.lastIndexOf(pattern);
150
- if (candidate >= searchFrom) lastPromptAt = candidate;
151
- }
152
-
153
- if (lastPromptAt >= 0 && Date.now() - lastGrowthAt >= stableMs) {
154
- session.matchOffset = lastPromptAt + pattern.length;
155
- return lastPromptAt;
156
259
  }
157
260
 
158
- if (session.streamDone) {
159
- if (lastPromptAt >= 0)
160
- session.matchOffset = lastPromptAt + pattern.length;
161
- return lastPromptAt;
261
+ const promptAt = trailingPrompt();
262
+ const quiet = Date.now() - lastGrowthAt >= stableMs;
263
+ if (promptAt >= 0 && (quiet || session.streamDone)) {
264
+ session.matchOffset = promptAt + pattern.length;
265
+ return { kind: "prompt", index: promptAt };
162
266
  }
267
+ if (session.streamDone) return null;
268
+ if (Date.now() >= deadline) return null;
163
269
 
164
270
  await Bun.sleep(FAST_POLL);
165
271
  }
166
- return -1;
167
272
  }
168
273
 
169
274
  /**
@@ -315,12 +420,70 @@ async function handlePostLogin(
315
420
  return false;
316
421
  }
317
422
 
423
+ /**
424
+ * Reduce the raw post-command console text to just the command's output.
425
+ *
426
+ * Measured shape of a RouterOS 7.21.5 serial reply (x86/HVF, QEMU 11.0.3):
427
+ *
428
+ * ```text
429
+ * <cmd>\r[admin@MikroTik] > <cmd>\r\n echo — ONE physical line, bare-\r redraws
430
+ * \r<output>\r\n output, wrapped at the terminal width
431
+ * \r\r\r\r[admin@MikroTik] > \r[admin@MikroTik] > <sentinel echo>\r\n
432
+ * \rQCHR…END\r\n the sentinel; the caller cuts before it
433
+ * ```
434
+ *
435
+ * Two structural rules do the work, and neither relies on matching the command
436
+ * text throughout — a long command is **horizontally scrolled** in the redraw
437
+ * (`<ss=nonsense; :put (…)`), so its opening characters are not present there:
438
+ *
439
+ * - the sentinel echo carries this call's `nonce`, and shares a physical line
440
+ * with the prompt redraw ahead of it, so dropping nonce-bearing lines removes
441
+ * both at once;
442
+ * - the command echo is the first physical line, and is dropped only when it
443
+ * actually looks like one (it carries a prompt redraw, or starts with the
444
+ * command) — so a reply with no echo cannot lose its first output line.
445
+ */
446
+ export function extractOutput(raw: string, command: string, nonce?: string): string {
447
+ const lines = stripAnsi(raw)
448
+ .split(/\r?\n/)
449
+ .filter((l) => nonce === undefined || !l.includes(nonce));
450
+
451
+ const cmdPrefix = command.trim().slice(0, 24);
452
+ const first = lines[0];
453
+ let startIdx =
454
+ first !== undefined &&
455
+ (first.includes(PROMPT_PATTERN) || (cmdPrefix !== "" && first.trimStart().startsWith(cmdPrefix)))
456
+ ? 1
457
+ : 0;
458
+ while (startIdx < lines.length && (lines[startIdx] ?? "").trim() === "") startIdx++;
459
+
460
+ // Trailing prompt fragments and blank lines are the redraw after the reply.
461
+ let endIdx = lines.length;
462
+ for (let i = lines.length - 1; i >= startIdx; i--) {
463
+ const trimmed = (lines[i] ?? "").trim();
464
+ if (trimmed === "" || trimmed.endsWith(PROMPT_PATTERN.trim())) endIdx = i;
465
+ else break;
466
+ }
467
+
468
+ return lines.slice(startIdx, endIdx).join("\n").trim();
469
+ }
470
+
471
+ /** Result of one console command.
472
+ *
473
+ * `framed` says the reply was delimited by the end-of-reply sentinel — i.e. the
474
+ * reader saw the whole reply. When it is false the reply was terminated by the
475
+ * fallback prompt heuristic instead, so an empty `output` means "could not frame
476
+ * the reply", not "the guest printed nothing" (#109). */
477
+ export interface ConsoleExecResult {
478
+ output: string;
479
+ framed: boolean;
480
+ }
481
+
318
482
  /**
319
483
  * Execute a RouterOS CLI command over the serial console.
320
484
  *
321
- * Connects to serial socket, ensures the session is logged in,
322
- * sends the command, and captures output between the command echo
323
- * and the next prompt.
485
+ * Connects to serial socket, ensures the session is logged in, sends the command
486
+ * followed by an end-of-reply sentinel, and captures the output between them.
324
487
  *
325
488
  * @param machineDir Machine directory containing serial.sock
326
489
  * @param command RouterOS CLI command to execute
@@ -335,7 +498,7 @@ export async function consoleExec(
335
498
  password: string = "",
336
499
  timeoutMs: number = DEFAULT_TIMEOUT_MS,
337
500
  portBase?: number,
338
- ): Promise<{ output: string }> {
501
+ ): Promise<ConsoleExecResult> {
339
502
  const session = openSession(machineDir, portBase);
340
503
 
341
504
  try {
@@ -367,62 +530,30 @@ export async function consoleExec(
367
530
  // pre-buffered "] > " from ANSI codes or OSC title sequences RouterOS emits.
368
531
  session.matchOffset = Math.max(session.matchOffset, preCommandOffset);
369
532
 
370
- // Send command
371
- write(session, `${command}\r`);
533
+ // Send the command and the end-of-reply sentinel as two console lines in one
534
+ // write. The sentinel is what terminates the reply; the prompt is only a
535
+ // fallback (see makeSentinel / waitForReplyEnd).
536
+ const sentinel = makeSentinel();
537
+ write(session, `${command}\r${sentinel.command}\r`);
372
538
 
373
- // Wait for the FINAL prompt after the command completes.
374
- // RouterOS VT100 redraws the prompt+command line during execution, so the
375
- // first "] > " is a redraw, not the output prompt. waitForFinalPrompt uses
376
- // buffer stability (no new data for 150ms) to identify the last prompt.
377
- const promptFound = await waitForFinalPrompt(session, PROMPT_PATTERN, preCommandOffset, timeoutMs);
378
- if (promptFound < 0) {
539
+ const end = await waitForReplyEnd(session, sentinel.marker, PROMPT_PATTERN, preCommandOffset, timeoutMs);
540
+ if (end === null) {
379
541
  throw new QuickCHRError(
380
542
  "BOOT_TIMEOUT",
381
- `Console exec: no prompt after command (timed out after ${timeoutMs}ms)`,
543
+ `Console exec: no end-of-reply sentinel and no prompt after command (timed out after ${timeoutMs}ms)`,
382
544
  );
383
545
  }
546
+ const framed = end.kind === "sentinel";
384
547
 
385
- // Extract output: everything between our command and the next prompt.
386
- // promptFound points at "] > " — back up to the newline before the prompt
387
- // line so we don't include the "[admin@...] > " prefix in output.
388
- const lastNewline = session.buffer.lastIndexOf("\n", promptFound);
389
- const sliceEnd = lastNewline >= preCommandOffset ? lastNewline : promptFound;
390
- const rawOutput = session.buffer.slice(preCommandOffset, sliceEnd);
391
-
392
- // Clean up: strip ANSI, strip the command echo line, trim
393
- const cleaned = stripAnsi(rawOutput);
394
- const lines = cleaned.split(/\r?\n/);
395
-
396
- // First non-empty line(s) are the command echo — skip them.
397
- // The echo repeats back what we typed.
398
- let startIdx = 0;
399
- for (let i = 0; i < lines.length; i++) {
400
- const line = lines[i];
401
- if (!line) continue;
402
- const trimmed = line.trim();
403
- // Skip echo lines that contain our command text
404
- if (trimmed.includes(command.trim()) || trimmed === "") {
405
- startIdx = i + 1;
406
- } else {
407
- break;
408
- }
409
- }
548
+ // Cut back to the newline that starts the terminator's own line, so neither
549
+ // the marker nor the "[admin@…] > " prefix lands in the output.
550
+ const lineStart = session.buffer.lastIndexOf("\n", end.index);
551
+ const sliceEnd = lineStart >= preCommandOffset ? lineStart : end.index;
410
552
 
411
- // Also strip trailing prompt fragments and empty lines
412
- let endIdx = lines.length;
413
- for (let i = lines.length - 1; i >= startIdx; i--) {
414
- const line = lines[i];
415
- if (!line) continue;
416
- const trimmed = line.trim();
417
- if (trimmed === "" || trimmed.endsWith(PROMPT_PATTERN.trim())) {
418
- endIdx = i;
419
- } else {
420
- break;
421
- }
422
- }
553
+ const rawOutput = session.buffer.slice(preCommandOffset, sliceEnd);
423
554
 
424
- const output = lines.slice(startIdx, endIdx).join("\n").trim();
425
- return { output };
555
+ const output = extractOutput(rawOutput, command, sentinel.nonce);
556
+ return { output, framed };
426
557
  } finally {
427
558
  session.socket.destroy();
428
559
  }