dsh-ensp 0.1.2 → 0.1.6

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.
@@ -30,6 +30,8 @@
30
30
 
31
31
  import { spawn, spawnSync } from 'node:child_process'
32
32
  import net from 'node:net'
33
+ import os from 'node:os'
34
+ import path from 'node:path'
33
35
 
34
36
  import { calibrateAnchors, DEFAULT_ANCHORS } from './calibrate.mjs'
35
37
 
@@ -180,17 +182,61 @@ ${body}
180
182
  return { ok: r.status === 0, stdout: (r.stdout || '').trim(), stderr: (r.stderr || '').trim() }
181
183
  }
182
184
 
183
- /** PID of the running eNSP client, or null. */
185
+ /**
186
+ * PID of a *live* eNSP client, or null.
187
+ *
188
+ * eNSP leaves zombie processes behind: the process object survives with
189
+ * `Threads.Count === 0` and holds the client's single-instance lock, so a new
190
+ * launch inherits an empty canvas. Such a PID must never be reused.
191
+ *
192
+ * @returns {number|null}
193
+ */
184
194
  export function enspPid() {
185
195
  const r = spawnSync(
186
196
  'powershell',
187
- ['-NoProfile', '-Command', '(Get-Process eNSP_Client -ErrorAction SilentlyContinue | Select-Object -First 1).Id'],
197
+ [
198
+ '-NoProfile',
199
+ '-Command',
200
+ 'Get-Process eNSP_Client -ErrorAction SilentlyContinue | ' +
201
+ 'Where-Object { $_.Threads.Count -gt 0 } | ' +
202
+ 'Select-Object -First 1 -ExpandProperty Id',
203
+ ],
188
204
  { encoding: 'utf8', windowsHide: true },
189
205
  )
190
206
  const pid = Number((r.stdout || '').trim())
191
207
  return Number.isInteger(pid) && pid > 0 ? pid : null
192
208
  }
193
209
 
210
+ /**
211
+ * Is this window handle still valid and owned by a live process?
212
+ *
213
+ * @param {string} hwnd
214
+ * @returns {boolean}
215
+ */
216
+ export function windowAlive(hwnd) {
217
+ const r = runPowerShell(
218
+ `$h = [IntPtr]::new([int64]${hwnd})
219
+ if (-not [EnspWin]::IsWindow($h)) { 'no'; exit 0 }
220
+ $owned = Get-Process eNSP_Client -ErrorAction SilentlyContinue | Where-Object { $_.Threads.Count -gt 0 }
221
+ if ($owned) { 'yes' } else { 'no' }`,
222
+ { timeoutMs: 20000 },
223
+ )
224
+ return r.ok && r.stdout.trim().endsWith('yes')
225
+ }
226
+
227
+ /** Every eNSP client PID, including zero-thread zombies. */
228
+ export function enspPids() {
229
+ const r = spawnSync(
230
+ 'powershell',
231
+ ['-NoProfile', '-Command', 'Get-Process eNSP_Client -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Id'],
232
+ { encoding: 'utf8', windowsHide: true },
233
+ )
234
+ return (r.stdout || '')
235
+ .split(/\s+/)
236
+ .map(Number)
237
+ .filter((n) => Number.isInteger(n) && n > 0)
238
+ }
239
+
194
240
  /**
195
241
  * Launch the eNSP client, optionally opening a topology.
196
242
  *
@@ -200,38 +246,236 @@ export function enspPid() {
200
246
  * @param {number} [options.readyTimeoutMs=90000] - how long to wait for the window to exist
201
247
  * @returns {Promise<{pid: number, hwnd: string}>}
202
248
  */
203
- export async function launchEnsp({ topology, exspPath, readyTimeoutMs = 90000 } = {}) {
249
+ export async function launchEnsp({ topology, exspPath, readyTimeoutMs = 120000 } = {}) {
204
250
  const exe = exspPath ?? process.env.DSH_ENSP_EXE ?? 'E:\\Huawei\\eNSP\\eNSP_Client.exe'
205
- const already = enspPid()
206
- if (already) {
207
- const hwnd = locateWindow(already)
208
- if (hwnd) return { pid: already, hwnd, reused: true }
251
+ const target = topology ? path.resolve(topology) : null
252
+
253
+ // Reuse an existing window only when it renders something; a blank window is a
254
+ // dead instance that would otherwise be reported as success.
255
+ const existing = enspPid()
256
+ if (existing) {
257
+ const hwnd = locateWindow(existing)
258
+ if (hwnd && (!target || topologyRendered(hwnd))) {
259
+ return { pid: existing, hwnd, reused: true, rendered: Boolean(hwnd) }
260
+ }
261
+ if (hwnd && target) {
262
+ // A live window showing nothing: reopen the file in it first.
263
+ reopenTopology(target)
264
+ if (await waitForRender(hwnd, { attempts: 6, intervalMs: 2500 })) {
265
+ return { pid: existing, hwnd, reused: true, rendered: true }
266
+ }
267
+ }
268
+ // Give up on it: stale clients hold eNSP's single-instance lock and make every
269
+ // new window open blank.
270
+ killAllClients()
271
+ await sleep(2500)
209
272
  }
210
273
 
211
- const args = topology ? [topology] : []
212
- const child = spawn(exe, args, { detached: true, stdio: 'ignore', windowsHide: false })
213
- child.unref()
274
+ /** Launch once and wait for a frame window. */
275
+ const boot = async () => {
276
+ startDetached(exe, target)
277
+ const started = Date.now()
278
+ const deadline = started + readyTimeoutMs
279
+ while (Date.now() < deadline) {
280
+ const pid = enspPid()
281
+ if (pid) {
282
+ const hwnd = locateWindow(pid)
283
+ if (hwnd) return { pid, hwnd, waitMs: Date.now() - started }
284
+ }
285
+ await sleep(250)
286
+ }
287
+ return null
288
+ }
214
289
 
215
- // Poll tightly: eNSP creates its frame before it finishes painting, and every
216
- // second spent here is dead time the caller cannot overlap with anything.
217
- const started = Date.now()
218
- const deadline = started + readyTimeoutMs
219
- let pid = null
220
- let hwnd = null
221
- while (Date.now() < deadline) {
222
- pid = enspPid()
223
- if (pid) {
224
- hwnd = locateWindow(pid)
225
- if (hwnd) return { pid, hwnd, waitMs: Date.now() - started }
290
+ // Up to three attempts: the client occasionally opens with an empty canvas on a
291
+ // cold start, and a clean relaunch always renders. Verified empirically.
292
+ for (let attempt = 1; attempt <= 3; attempt++) {
293
+ const booted = await boot()
294
+ if (!booted) {
295
+ killAllClients()
296
+ await sleep(1500)
297
+ continue
298
+ }
299
+ if (!target) return { ...booted, reused: false, rendered: true }
300
+ if (await waitForRender(booted.hwnd, { attempts: 6, intervalMs: 2000 })) {
301
+ return { ...booted, reused: false, rendered: true }
226
302
  }
227
- await sleep(250)
303
+ // Blank canvas: tear the instance down and try again from a clean state.
304
+ killAllClients()
305
+ await sleep(2500)
228
306
  }
307
+
229
308
  throw new Error(
230
- `eNSP window did not appear within ${readyTimeoutMs} ms` +
231
- (pid ? ` (pid ${pid} started but no frame window)` : ' (process never started)'),
309
+ 'eNSP opened but never rendered the topology after 3 attempts. ' +
310
+ 'Close every eNSP window and retry; a leftover client holds the single-instance lock.',
232
311
  )
233
312
  }
234
313
 
314
+ /**
315
+ * Force-terminate every eNSP client process.
316
+ *
317
+ * Zombie processes (zero threads) cannot be killed by any API; they linger until
318
+ * the OS reaps them. This reports what survived so the caller can decide whether
319
+ * retrying is worthwhile.
320
+ *
321
+ * @returns {{killed: boolean, survivors: number[]}}
322
+ */
323
+ function killAllClients() {
324
+ spawnSync('taskkill', ['/F', '/IM', 'eNSP_Client.exe'], { windowsHide: true, encoding: 'utf8' })
325
+ return { killed: true, survivors: enspPids() }
326
+ }
327
+
328
+ /**
329
+ * Poll until the canvas actually shows the topology.
330
+ *
331
+ * @param {string} hwnd
332
+ * @param {{attempts?: number, intervalMs?: number}} [options]
333
+ * @returns {Promise<boolean>}
334
+ */
335
+ async function waitForRender(hwnd, { attempts = 6, intervalMs = 2000 } = {}) {
336
+ for (let i = 0; i < attempts; i++) {
337
+ await sleep(intervalMs)
338
+ if (topologyRendered(hwnd)) return true
339
+ }
340
+ return false
341
+ }
342
+
343
+ /**
344
+ * Start the client so that it outlives this Node process.
345
+ *
346
+ * `spawn(..., {detached:true})` is not enough on Windows: eNSP is a GUI application
347
+ * that terminates when the console session that launched it goes away. Going
348
+ * through `cmd /c start` hands ownership to the shell, which is what a double-click
349
+ * in Explorer does, and the client then survives its launcher.
350
+ *
351
+ * @param {string} exe
352
+ * @param {string|null} [topology]
353
+ */
354
+ function startDetached(exe, topology) {
355
+ // Win32_Process.Create via WMI is the only launch path measured to leave the
356
+ // client fully independent of this Node process:
357
+ //
358
+ // spawn(..., {detached:true}) -> client dies with its launcher
359
+ // cmd /c start -> client dies with its launcher
360
+ // Start-Process -WindowStyle Hidden -> client exits before creating a window
361
+ // Start-Process (visible) -> unreliable when called from a hidden shell
362
+ // WMI Win32_Process.Create -> survives; returns the real PID
363
+ //
364
+ // The client must outlive the tool call, otherwise every follow-up (start
365
+ // devices, push config) finds a dead console.
366
+ const cmdline = topology ? `"${exe}" "${topology}"` : `"${exe}"`
367
+ const r = runPowerShell(
368
+ `
369
+ $r = Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments @{
370
+ CommandLine = '${cmdline.replace(/'/g, "''")}'
371
+ }
372
+ if ($r.ReturnValue -ne 0) { 'ERR ' + $r.ReturnValue; exit 0 }
373
+ 'pid=' + $r.ProcessId
374
+ `,
375
+ { timeoutMs: 30000 },
376
+ )
377
+ if (!r.ok || !/pid=\d+/.test(r.stdout)) {
378
+ throw new Error(`could not launch eNSP: ${r.stdout || r.stderr || 'WMI refused the request'}`)
379
+ }
380
+ return Number(/pid=(\d+)/.exec(r.stdout)[1])
381
+ }
382
+
383
+ /**
384
+ * Ask the running client to open a topology again.
385
+ *
386
+ * A second instance is normally refused, so this relaunches the client with the
387
+ * same file; the window handle changes and the caller re-locates it.
388
+ *
389
+ * @param {string} topology
390
+ */
391
+ function reopenTopology(topology) {
392
+ const exe = process.env.DSH_ENSP_EXE ?? 'E:\\Huawei\\eNSP\\eNSP_Client.exe'
393
+ startDetached(exe, topology)
394
+ }
395
+
396
+ /**
397
+ * Take a screenshot of the client's canvas, cropped to the drawing area.
398
+ *
399
+ * Used by `topologyRendered` and by the probe diagnostics. The canvas excludes the
400
+ * left device palette and the toolbar, so anything found inside it came from the
401
+ * open topology.
402
+ *
403
+ * @param {string} hwnd
404
+ * @returns {string} absolute path to the PNG, or '' on failure
405
+ */
406
+ export function captureCanvas(hwnd) {
407
+ const out = path.join(os.tmpdir(), `dsh-ensp-canvas-${Date.now()}.png`)
408
+ const r = runPowerShell(
409
+ `
410
+ $h = [IntPtr]::new([int64]${hwnd})
411
+ [void][EnspWin]::FocusWindow($h)
412
+ Start-Sleep -Milliseconds 900
413
+ $wr = New-Object EnspWin+RECT
414
+ [void][EnspWin]::GetWindowRect($h, [ref]$wr)
415
+ $w = $wr.R - $wr.L; $ht = $wr.B - $wr.T
416
+ if ($w -lt 400 -or $ht -lt 300) { 'ERR small'; exit 0 }
417
+ Add-Type -AssemblyName System.Drawing
418
+ $bmp = New-Object System.Drawing.Bitmap $w, $ht
419
+ $g = [System.Drawing.Graphics]::FromImage($bmp)
420
+ $g.CopyFromScreen($wr.L, $wr.T, 0, 0, $bmp.Size)
421
+ $bmp.Save('${out.replace(/\\/g, '\\\\')}', [System.Drawing.Imaging.ImageFormat]::Png)
422
+ $bmp.Dispose(); $g.Dispose()
423
+ 'ok'
424
+ `,
425
+ { timeoutMs: 40000 },
426
+ )
427
+ return r.ok ? out : ''
428
+ }
429
+
430
+ /**
431
+ * Does the open topology show devices on the canvas?
432
+ *
433
+ * eNSP paints with DirectX, so there is no window tree to query. Two independent
434
+ * signals are combined, because either alone is weak:
435
+ *
436
+ * 1. **Icon colour.** Device icons are a saturated blue on a pale canvas; the
437
+ * empty canvas has no such pixels.
438
+ * 2. **Link lines.** Rendered links are near-black thin strokes across the canvas.
439
+ *
440
+ * This is the check that gates "the topology actually opened". Callers retry on a
441
+ * negative result rather than reporting success for a blank window.
442
+ *
443
+ * @param {string} hwnd
444
+ * @returns {boolean}
445
+ */
446
+ export function topologyRendered(hwnd) {
447
+ const png = captureCanvas(hwnd)
448
+ if (!png) return false
449
+ const r = runPowerShell(
450
+ `
451
+ Add-Type -AssemblyName System.Drawing
452
+ $bmp = [System.Drawing.Bitmap]::FromFile('${png.replace(/\\/g, '\\\\')}')
453
+ $w = $bmp.Width; $ht = $bmp.Height
454
+ # Canvas only: skip the palette column and the toolbar band.
455
+ $x0 = [int]($w * 0.22); $x1 = [int]($w * 0.97)
456
+ $y0 = [int]($ht * 0.10); $y1 = [int]($ht * 0.92)
457
+ $icon = 0; $dark = 0
458
+ for ($y = $y0; $y -lt $y1; $y += 2) {
459
+ for ($x = $x0; $x -lt $x1; $x += 2) {
460
+ $c = $bmp.GetPixel($x, $y)
461
+ if ($c.B -gt 140 -and ($c.B - $c.R) -gt 55 -and ($c.B - $c.G) -gt 25) { $icon++ }
462
+ elseif ($c.R -lt 90 -and $c.G -lt 90 -and $c.B -lt 90) { $dark++ }
463
+ }
464
+ }
465
+ $bmp.Dispose()
466
+ 'icon=' + $icon + ' dark=' + $dark
467
+ `,
468
+ { timeoutMs: 40000 },
469
+ )
470
+ if (!r.ok) return false
471
+ const m = /icon=(\d+) dark=(\d+)/.exec(r.stdout)
472
+ if (!m) return false
473
+ const icon = Number(m[1])
474
+ const dark = Number(m[2])
475
+ // A rendered lab shows dozens of icon pixels and a few hundred link pixels.
476
+ return icon >= 15 && dark >= 40
477
+ }
478
+
235
479
  /**
236
480
  * Locate the eNSP main frame window for a PID.
237
481
  * @param {number} pid
@@ -399,6 +643,12 @@ export async function startAllDevices({
399
643
 
400
644
  // Locate the toolbar buttons by colour instead of trusting hard-coded pixels;
401
645
  // this survives eNSP upgrades, DPI changes and different display sizes.
646
+ // The window must still exist: a stale handle means the client already exited,
647
+ // and clicking a dead handle does nothing while reporting success below.
648
+ if (!hwnd || !windowAlive(hwnd)) {
649
+ throw new Error('eNSP window is gone; reopen the topology before starting devices')
650
+ }
651
+
402
652
  report({ phase: 'calibrate', hwnd })
403
653
  let anchors
404
654
  try {
@@ -406,6 +656,16 @@ export async function startAllDevices({
406
656
  } catch (error) {
407
657
  anchors = { ...DEFAULT_ANCHORS, method: 'default', detail: { reason: error.message } }
408
658
  }
659
+
660
+ // A default anchor means calibration failed, and an unmeasured click can land on
661
+ // any toolbar control (including the one that closes the document). Refuse
662
+ // instead of guessing.
663
+ if (anchors.method !== 'measured') {
664
+ throw new Error(
665
+ `toolbar calibration failed (${anchors.detail?.reason ?? 'unknown'}). ` +
666
+ 'Make sure the eNSP window is visible and run ensp_probe to re-measure.',
667
+ )
668
+ }
409
669
  report({ phase: 'calibrated', method: anchors.method, detail: anchors.detail })
410
670
 
411
671
  report({ phase: 'focus', hwnd })
@@ -280,3 +280,84 @@ export async function queryDevice({ port, command, timeoutMs = 25000 }) {
280
280
  session?.close()
281
281
  }
282
282
  }
283
+
284
+ /**
285
+ * Poll a device until a predicate accepts its command output, or time runs out.
286
+ *
287
+ * Routing protocols converge asynchronously: right after `ospf 1` is committed a
288
+ * peering sits in ExStart/Exchange/Loading for a while, and a single query taken
289
+ * too early reads as "failed" when it is merely "not finished". Waiting a fixed
290
+ * number of seconds either wastes time or is still too short on a slow machine,
291
+ * so every convergence assertion in this package goes through this helper.
292
+ *
293
+ * @param {{
294
+ * port: number,
295
+ * command: string,
296
+ * until: (output: string) => boolean,
297
+ * timeoutMs?: number,
298
+ * intervalMs?: number,
299
+ * probeTimeoutMs?: number,
300
+ * }} options
301
+ * @returns {Promise<{ok: boolean, converged: boolean, output: string, attempts: number, waitedMs: number}>}
302
+ */
303
+ export async function waitForOutput({
304
+ port,
305
+ command,
306
+ until,
307
+ timeoutMs = 120000,
308
+ intervalMs = 3000,
309
+ probeTimeoutMs = 20000,
310
+ }) {
311
+ const started = Date.now()
312
+ let attempts = 0
313
+ let output = ''
314
+ while (Date.now() - started < timeoutMs) {
315
+ attempts += 1
316
+ const r = await queryDevice({ port, command, timeoutMs: probeTimeoutMs })
317
+ if (r.ok) output = r.output
318
+ if (r.ok && until(r.output)) {
319
+ return { ok: true, converged: true, output, attempts, waitedMs: Date.now() - started }
320
+ }
321
+ await sleep(intervalMs)
322
+ }
323
+ return {
324
+ ok: false,
325
+ converged: false,
326
+ output,
327
+ attempts,
328
+ waitedMs: Date.now() - started,
329
+ }
330
+ }
331
+
332
+ /**
333
+ * Wait until an OSPF adjacency reaches Full on `port`, optionally naming the peer.
334
+ *
335
+ * @param {{port: number, neighbourId?: string, timeoutMs?: number}} options
336
+ */
337
+ export async function waitForOspfFull({ port, neighbourId, timeoutMs = 120000 }) {
338
+ const r = await waitForOutput({
339
+ port,
340
+ command: 'display ospf peer brief',
341
+ until: (out) =>
342
+ out
343
+ .split('\n')
344
+ .filter((l) => /\bFull\b/.test(l))
345
+ .some((l) => !neighbourId || l.includes(neighbourId)),
346
+ timeoutMs,
347
+ })
348
+ return { ...r, neighbourId: neighbourId || null }
349
+ }
350
+
351
+ /**
352
+ * Wait until a prefix shows up in the OSPF routing table on `port`.
353
+ *
354
+ * @param {{port: number, prefix: string, timeoutMs?: number}} options
355
+ */
356
+ export async function waitForOspfRoute({ port, prefix, timeoutMs = 120000 }) {
357
+ return waitForOutput({
358
+ port,
359
+ command: 'display ip routing-table protocol ospf',
360
+ until: (out) => out.includes(prefix),
361
+ timeoutMs,
362
+ })
363
+ }
@@ -43,8 +43,25 @@ const MODEL_SPECS = {
43
43
  Cloud: { groups: [['Ethernet', 'Ethernet', 3, 0]] },
44
44
  }
45
45
 
46
- /** Icon bounding box offsets used for cable anchors. */
47
- const ICON = { w: 86, h: 86, left: -16.2, right: 70.2 }
46
+ /**
47
+ * Icon bounding box, measured from every cable anchor in eNSP's own samples.
48
+ *
49
+ * The box is **not** centred on (cx, cy): it runs from `cx - 16.2` to
50
+ * `cx + 70.2` horizontally, so its centre is `cx + 27`, not `cx + 43`. Using
51
+ * `height / 2` puts the anchor outside the icon and eNSP then rejects the whole
52
+ * `<lines>` block, which shows up as an empty canvas with no error message.
53
+ *
54
+ * Frequencies across the bundled samples: (+70.2,+27.0) x35, (-16.2,+27.0) x35,
55
+ * (+27.0,+70.2) x29, (+27.0,-16.2) x27.
56
+ */
57
+ const ICON = {
58
+ left: -16.2,
59
+ right: 70.2,
60
+ top: -16.2,
61
+ bottom: 70.2,
62
+ centerX: 27.0,
63
+ centerY: 27.0,
64
+ }
48
65
 
49
66
  /**
50
67
  * Interfaces exposed by each model eNSP can run, in declaration order.
@@ -255,11 +272,12 @@ export function serialiseTopology(topo) {
255
272
  const dst = byId.get(l.dstId)
256
273
  if (!src || !dst) throw new Error(`link references unknown device: ${l.srcId} -> ${l.dstId}`)
257
274
 
258
- // Anchor on the facing sides: right edge of the source, left edge of the target.
275
+ // Anchor on the facing sides: right edge of the source, left edge of the target,
276
+ // both at the icon's true vertical centre.
259
277
  const sx = src.x + ICON.right
260
- const sy = src.y + ICON.h / 2
278
+ const sy = src.y + ICON.centerY
261
279
  const tx = dst.x + ICON.left
262
- const ty = dst.y + ICON.h / 2
280
+ const ty = dst.y + ICON.centerY
263
281
 
264
282
  return (
265
283
  ` <line srcDeviceID="${src.id}" destDeviceID="${dst.id}">\n` +