dsh-winrm 0.1.1 → 0.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.
@@ -2,11 +2,11 @@
2
2
  * WinRM transport bridge.
3
3
  *
4
4
  * Runs each PowerShell payload over a native Node.js WinRM client
5
- * (winrm-client) — no Python/pywinrm subprocess. The script rides the
6
- * UTF-8 base64 envelope (marker + base64 of UTF-8 output) so Chinese and
7
- * any codepage output survives the WinRM transport losslessly; the envelope
8
- * is base64-encoded with `-EncodedCommand`, keeping the command line a
9
- * single ASCII-safe line that WinRS/cmd.exe cannot mangle.
5
+ * (winrm-client) — no Python/pywinrm subprocess. The script is delivered on
6
+ * the WinRS **stdin** stream and answers on the UTF-8 base64 envelope, so
7
+ * Chinese and any codepage output survives the transport losslessly and the
8
+ * command line stays a constant, ASCII-safe line that WinRS/cmd.exe cannot
9
+ * mangle. See powershell.ts for why the command line cannot carry the script.
10
10
  *
11
11
  * Auth is chosen per attempt (HTTPS: Basic first, then NTLM; HTTP: NTLM
12
12
  * first, then Basic) with fallback, so a host that only enables one scheme
@@ -14,10 +14,28 @@
14
14
  * never appear in a child-process argument list.
15
15
  */
16
16
 
17
+ import { createGunzip, gzipSync } from 'node:zlib'
17
18
  import { Command, Shell } from 'winrm-client'
18
19
  import type { WinHostEntry } from '../protocol.ts'
19
- import { parseEnvelope, powershellCommandLine, psFileSize, psReadChunk, psWriteChunk, stripClixml, winrmPowerShellScript } from '../powershell.ts'
20
-
20
+ import {
21
+ buildStreamFrame,
22
+ parseEnvelope,
23
+ powershellCommandLine,
24
+ psFileSize,
25
+ psReadChunk,
26
+ psReceiveStream,
27
+ psSendStream,
28
+ psWriteChunk,
29
+ scriptCommandLine,
30
+ scriptPayload,
31
+ splitScriptPayload,
32
+ STREAM_FRAME_GZIP,
33
+ STREAM_FRAME_HEADER,
34
+ STREAM_FRAME_RAW,
35
+ streamCommandLine,
36
+ stripClixml,
37
+ } from '../powershell.ts'
38
+ import { isEnvelopeTooLarge, loadSendHttp, receiveOutput, sendStdinChunk, type WsmanTarget } from './wsman.ts'
21
39
  /** Connection parameters for one target (transport-agnostic projection of a host entry). */
22
40
  export interface WinRMParams {
23
41
  host: string
@@ -86,11 +104,11 @@ function baseParams(conn: WinRMParams, authMethod: WinrmAuth) {
86
104
  }
87
105
 
88
106
  /**
89
- * One shell lifecycle: create → run the encoded script → receive until the
90
- * command completes → delete. Bounded by an overall deadline; on timeout the
91
- * delete is fired without blocking the caller.
107
+ * One shell lifecycle: create → run the constant envelope script → push the
108
+ * payload on stdin → receive until the command completes → delete. Bounded by
109
+ * an overall deadline; on timeout the delete is fired without blocking.
92
110
  * @param conn - connection parameters.
93
- * @param envelope - the wrapped UTF-8 envelope script (already prepared).
111
+ * @param payload - the stdin payload (`<byteCount>\n<script>`).
94
112
  * @param timeoutMs - command execution budget measured from just after Execute.
95
113
  * @param authMethod - the auth scheme for this attempt.
96
114
  * @returns stdout/stderr captured from the WinRS streams.
@@ -98,7 +116,7 @@ function baseParams(conn: WinRMParams, authMethod: WinrmAuth) {
98
116
  */
99
117
  async function attemptOnce(
100
118
  conn: WinRMParams,
101
- envelope: string,
119
+ payload: string,
102
120
  timeoutMs: number,
103
121
  authMethod: WinrmAuth,
104
122
  ): Promise<{ stdout: string; stderr: string }> {
@@ -117,8 +135,10 @@ async function attemptOnce(
117
135
  void Shell.doDeleteShell({ ...base, shellId }).catch(() => undefined)
118
136
  throw new WinrmTimedOut()
119
137
  }
120
- const command = powershellCommandLine(envelope)
121
- const commandId = await Command.doExecuteCommand({ ...base, shellId, command })
138
+ const commandId = await Command.doExecuteCommand({ ...base, shellId, command: powershellCommandLine() })
139
+ for (const piece of splitScriptPayload(payload)) {
140
+ await Command.doSendInput({ ...base, shellId, commandId, input: piece })
141
+ }
122
142
  const receive = { ...base, shellId, commandId }
123
143
  const commandDeadline = Date.now() + timeoutMs
124
144
  let stdout = ''
@@ -156,21 +176,21 @@ async function attemptOnce(
156
176
  * succeeds. A timeout aborts immediately (retrying auth would waste the
157
177
  * remaining budget on a reachable-but-slow host).
158
178
  * @param conn - connection parameters.
159
- * @param envelope - the wrapped UTF-8 envelope script.
179
+ * @param payload - the stdin payload (`<byteCount>\n<script>`).
160
180
  * @param timeoutMs - command execution budget.
161
181
  * @returns captured stdout/stderr.
162
182
  * @throws WinrmTimedOut on deadline; the last auth/transport error when every scheme fails.
163
183
  */
164
184
  async function runWinrm(
165
185
  conn: WinRMParams,
166
- envelope: string,
186
+ payload: string,
167
187
  timeoutMs: number,
168
188
  ): Promise<{ stdout: string; stderr: string }> {
169
189
  const candidates: WinrmAuth[] = conn.useHttps ? ['basic', 'ntlm'] : ['ntlm', 'basic']
170
190
  let lastError: unknown
171
191
  for (const authMethod of candidates) {
172
192
  try {
173
- return await attemptOnce(conn, envelope, timeoutMs, authMethod)
193
+ return await attemptOnce(conn, payload, timeoutMs, authMethod)
174
194
  } catch (error) {
175
195
  if (error instanceof WinrmTimedOut) throw error
176
196
  lastError = error
@@ -186,11 +206,11 @@ export async function runScript(
186
206
  ): Promise<{ stdout: string; stderr: string; exitCode: number | null; timedOut: boolean; durationMs: number }> {
187
207
  const started = Date.now()
188
208
  const timeoutMs = options.timeoutMs ?? 60_000
189
- const envelope = winrmPowerShellScript(script)
209
+ const payload = scriptPayload(script)
190
210
 
191
211
  let outcome: { stdout: string; stderr: string }
192
212
  try {
193
- outcome = await runWinrm(conn, envelope, timeoutMs)
213
+ outcome = await runWinrm(conn, payload, timeoutMs)
194
214
  } catch (error) {
195
215
  if (error instanceof WinrmTimedOut) {
196
216
  return { stdout: '', stderr: '', exitCode: null, timedOut: true, durationMs: Date.now() - started }
@@ -207,10 +227,10 @@ export async function runScript(
207
227
  const exitCode = parsed !== null ? parsed.exitCode : 1
208
228
  // A script that reached the envelope leaves only the host's CLIXML records
209
229
  // on stderr; an aborted one keeps its stderr untouched for diagnosis.
210
- const stderr = parsed !== null ? stripClixml(outcome.stderr) : outcome.stderr
230
+ const stderr = parsed !== null ? stripClixml(parsed.stderr) : outcome.stderr
211
231
  options.onChunk?.({ output: raw, stderr })
212
232
  return {
213
- stdout: parsed !== null ? parsed.text : raw,
233
+ stdout: parsed !== null ? parsed.stdout : raw,
214
234
  stderr,
215
235
  exitCode,
216
236
  timedOut: false,
@@ -242,10 +262,18 @@ export async function testConnection(conn: WinRMParams): Promise<{ ok: boolean;
242
262
 
243
263
  export const TRANSFER_CHUNK = 48 * 1024
244
264
 
245
- export async function remoteFileSize(conn: WinRMParams, remotePath: string, timeoutMs = 60_000): Promise<number> {
265
+ /**
266
+ * Size of a remote file, or null when the path does not exist or is a
267
+ * directory. The distinction matters: a caller that treats "absent" as "empty"
268
+ * writes an empty local file and reports success for a download that copied
269
+ * nothing.
270
+ */
271
+ export async function remoteFileSize(conn: WinRMParams, remotePath: string, timeoutMs = 60_000): Promise<number | null> {
246
272
  const result = await runScript(conn, psFileSize(remotePath), { timeoutMs })
247
- const value = Number.parseInt(result.stdout.trim(), 10)
248
- return Number.isFinite(value) ? value : 0
273
+ const text = result.stdout.trim()
274
+ if (text === '') return null
275
+ const value = Number.parseInt(text, 10)
276
+ return Number.isFinite(value) && value >= 0 ? value : null
249
277
  }
250
278
 
251
279
  export async function downloadChunks(
@@ -256,6 +284,7 @@ export async function downloadChunks(
256
284
  timeoutMs = 60_000,
257
285
  ): Promise<number> {
258
286
  const total = await remoteFileSize(conn, remotePath, timeoutMs)
287
+ if (total === null) throw new Error(`remote file not found: ${remotePath}`)
259
288
  if (total === 0) return 0
260
289
  let offset = 0
261
290
  for (;;) {
@@ -289,3 +318,410 @@ export async function uploadBuffer(
289
318
  }
290
319
  return written
291
320
  }
321
+
322
+ /** Raw slice size for the streaming path: 256 KiB, comfortably inside `MAX_SEND_SLICE_BYTES`. */
323
+ const STREAM_CHUNK_BYTES = 262_144
324
+
325
+ /**
326
+ * Compress a slice only when it actually shrinks.
327
+ *
328
+ * Gzip on already-compressed input (zips, images, installers) produces output
329
+ * marginally *larger* than its input, and the remote side then pays to inflate
330
+ * it for nothing — measured at ~3.8x the wall time of sending the same bytes
331
+ * raw. Local compression is cheap (~3.4 ms per 256 KiB even when it fails to
332
+ * compress), so probing every slice costs almost nothing and the ratio is the
333
+ * only thing that decides.
334
+ */
335
+ const STREAM_GZIP_MIN_RATIO = 0.85
336
+
337
+ /** Per-Send HTTP budget; a single slice is small, so this only bounds a stalled connection. */
338
+ const STREAM_SEND_TIMEOUT_MS = 120_000
339
+
340
+ /**
341
+ * One streaming transfer over a single shell: create → run the receiver →
342
+ * push the receiver script, the header frame and every data frame down stdin →
343
+ * receive the envelope → delete the shell.
344
+ *
345
+ * The receiver script is the first thing on stdin because it does not fit the
346
+ * command line (see `streamCommandLine`); the bootstrap in the command line
347
+ * reads it and runs it in-process so it can keep reading the same stream.
348
+ * @param conn - connection parameters.
349
+ * @param remotePath - destination path on the target.
350
+ * @param data - the file contents to send.
351
+ * @param onProgress - called with cumulative bytes after each frame.
352
+ * @param timeoutMs - whole-transfer budget.
353
+ * @param authMethod - the auth scheme for this attempt.
354
+ * @param sendHttp - the resolved library send function.
355
+ * @returns the number of file bytes the receiver acknowledged writing.
356
+ * @throws WinrmTimedOut when the deadline passes; other errors are transport failures.
357
+ */
358
+ async function streamOnce(
359
+ conn: WinRMParams,
360
+ remotePath: string,
361
+ data: Buffer,
362
+ onProgress: ((transferred: number, total: number) => void) | undefined,
363
+ timeoutMs: number,
364
+ authMethod: WinrmAuth,
365
+ sendHttp: ReturnType<typeof loadSendHttp> & object,
366
+ ): Promise<number> {
367
+ const base = baseParams(conn, authMethod)
368
+ const target: WsmanTarget = {
369
+ host: conn.host,
370
+ port: conn.port,
371
+ path: conn.path,
372
+ username: conn.username,
373
+ password: conn.password,
374
+ authMethod,
375
+ useHttps: conn.useHttps ?? false,
376
+ rejectUnauthorized: conn.rejectUnauthorized ?? true,
377
+ }
378
+ const hardDeadline = Date.now() + timeoutMs + SETUP_GRACE_MS
379
+ const state = { shellId: undefined as string | undefined, timedOut: false, settled: false }
380
+ const total = data.length
381
+
382
+ const inner = async (): Promise<number> => {
383
+ const shellId = await Shell.doCreateShell(base)
384
+ state.shellId = shellId
385
+ if (state.settled) {
386
+ void Shell.doDeleteShell({ ...base, shellId }).catch(() => undefined)
387
+ throw new WinrmTimedOut()
388
+ }
389
+ const commandId = await Command.doExecuteCommand({ ...base, shellId, command: streamCommandLine() })
390
+
391
+ // 1. The receiver itself, in the `<byteCount>\n<script>` shape the
392
+ // bootstrap expects. This is not a DSHZ frame — it is what the
393
+ // command line's bootstrap reads before any framing starts.
394
+ await sendStdinChunk(target, shellId, commandId, Buffer.from(scriptPayload(psReceiveStream()), 'utf8'), sendHttp, STREAM_SEND_TIMEOUT_MS)
395
+
396
+ // 2. The transfer header: total byte count and destination path, which is
397
+ // how the receiver learns both without them touching the command line.
398
+ const header = Buffer.from(String(total) + '\n' + remotePath, 'utf8')
399
+ await sendStdinChunk(target, shellId, commandId, buildStreamFrame(header, STREAM_FRAME_HEADER), sendHttp, STREAM_SEND_TIMEOUT_MS)
400
+
401
+ // 3. Data frames. Slice size halves on a size rejection and recovers
402
+ // afterwards, so a host with a smaller envelope ceiling still converges
403
+ // without the caller knowing its limit.
404
+ let offset = 0
405
+ let slice = STREAM_CHUNK_BYTES
406
+ let ceiling = STREAM_CHUNK_BYTES
407
+ while (offset < total) {
408
+ if (state.settled || Date.now() >= hardDeadline) throw new WinrmTimedOut()
409
+ const take = Math.min(slice, total - offset)
410
+ const chunk = data.subarray(offset, offset + take)
411
+ const gz = gzipSync(chunk, { level: 6 })
412
+ const compress = gz.length < chunk.length * STREAM_GZIP_MIN_RATIO
413
+ const frame = buildStreamFrame(compress ? gz : chunk, compress ? STREAM_FRAME_GZIP : STREAM_FRAME_RAW)
414
+ try {
415
+ await sendStdinChunk(target, shellId, commandId, frame, sendHttp, STREAM_SEND_TIMEOUT_MS)
416
+ } catch (error) {
417
+ if (isEnvelopeTooLarge(error) && slice > 16_384) {
418
+ slice = Math.max(16_384, Math.floor(slice / 2))
419
+ ceiling = slice
420
+ continue
421
+ }
422
+ throw error
423
+ }
424
+ offset += take
425
+ onProgress?.(offset, total)
426
+ if (slice < ceiling) slice = Math.min(ceiling, slice * 2)
427
+ }
428
+
429
+ // 4. The receiver stops on its own once the declared byte count lands, so
430
+ // there is nothing to close — just wait for its envelope.
431
+ const receive = { ...base, shellId, commandId }
432
+ const commandDeadline = Date.now() + timeoutMs
433
+ let stdout = ''
434
+ let stderr = ''
435
+ for (;;) {
436
+ if (state.settled || Date.now() >= commandDeadline) throw new WinrmTimedOut()
437
+ const chunk = await Command.doReceiveOutputNonBlocking(receive)
438
+ stdout += chunk.output
439
+ stderr += chunk.stderr
440
+ if (chunk.isComplete) break
441
+ }
442
+
443
+ const parsed = parseEnvelope(stdout)
444
+ if (parsed === null) {
445
+ throw new Error('streaming upload did not report completion: ' + (stripClixml(stderr) || stdout).trim())
446
+ }
447
+ if (parsed.exitCode !== 0) {
448
+ throw new Error('streaming upload failed on the target: ' + parsed.stdout.trim())
449
+ }
450
+ const written = Number.parseInt(parsed.stdout.trim(), 10)
451
+ if (!Number.isFinite(written) || written !== total) {
452
+ throw new Error(`streaming upload wrote ${parsed.stdout.trim()} of ${total} bytes`)
453
+ }
454
+ return written
455
+ }
456
+
457
+ try {
458
+ return await raceDeadline(inner(), hardDeadline - Date.now())
459
+ } catch (error) {
460
+ if (error instanceof WinrmTimedOut || Date.now() >= hardDeadline) {
461
+ state.timedOut = true
462
+ throw error instanceof WinrmTimedOut ? error : new WinrmTimedOut()
463
+ }
464
+ throw error
465
+ } finally {
466
+ state.settled = true
467
+ const shellId = state.shellId
468
+ if (shellId !== undefined) {
469
+ const deletion = Shell.doDeleteShell({ ...base, shellId }).catch(() => undefined)
470
+ if (state.timedOut) void deletion
471
+ else await Promise.race([deletion, sleep(DELETE_TIMEOUT_MS)])
472
+ }
473
+ }
474
+ }
475
+
476
+ /**
477
+ * Upload a buffer over a single WinRS stdin stream.
478
+ *
479
+ * This replaces the per-chunk shell cycle: one shell, one command and one
480
+ * `Send` per 256 KiB, against a receiver that drains the stream straight into
481
+ * the file. Measured at ~10 s for 2 MiB where the old path took 310 s.
482
+ *
483
+ * Returns null — rather than throwing — when the transport this path depends
484
+ * on is unavailable (a future `winrm-client` layout that drops the internal
485
+ * module it borrows), so the caller can fall back to {@link uploadBuffer}
486
+ * instead of failing the transfer.
487
+ * @param conn - connection parameters.
488
+ * @param remotePath - destination path on the target.
489
+ * @param data - the file contents to send.
490
+ * @param onProgress - called with cumulative bytes after each frame.
491
+ * @param timeoutMs - whole-transfer budget.
492
+ * @returns bytes written, or null when the streaming path is unavailable.
493
+ */
494
+ export async function uploadStream(
495
+ conn: WinRMParams,
496
+ remotePath: string,
497
+ data: Buffer,
498
+ onProgress?: (transferred: number, total: number) => void,
499
+ timeoutMs = 600_000,
500
+ ): Promise<number | null> {
501
+ const sendHttp = loadSendHttp()
502
+ if (sendHttp === null) return null
503
+ const candidates: WinrmAuth[] = conn.useHttps ? ['basic', 'ntlm'] : ['ntlm', 'basic']
504
+ let lastError: unknown
505
+ for (const authMethod of candidates) {
506
+ try {
507
+ return await streamOnce(conn, remotePath, data, onProgress, timeoutMs, authMethod, sendHttp)
508
+ } catch (error) {
509
+ if (error instanceof WinrmTimedOut) throw error
510
+ lastError = error
511
+ }
512
+ }
513
+ throw lastError instanceof Error ? lastError : new Error('all WinRM authentication methods failed')
514
+ }
515
+
516
+ /** Smallest byte range worth giving a worker; below this the shell setup dominates. */
517
+ const DOWNLOAD_MIN_RANGE_BYTES = 524_288
518
+ /** Most shells to open for one download. Measured 3.9x on 16 MiB at four; more adds target load. */
519
+ const DOWNLOAD_MAX_WORKERS = 4
520
+ /** Per-Receive HTTP timeout. The server long-polls, so this only bounds a stalled transport. */
521
+ const DOWNLOAD_RECEIVE_TIMEOUT_MS = 120_000
522
+
523
+ /**
524
+ * One worker's slice of a download: own shell, own command, own byte range.
525
+ * @param conn - connection parameters.
526
+ * @param remotePath - file being read.
527
+ * @param start - first byte offset of this worker's range.
528
+ * @param length - bytes this worker must deliver.
529
+ * @param onData - receives each decompressed piece together with its file offset.
530
+ * @param timeoutMs - budget for the whole range.
531
+ * @param authMethod - the auth scheme for this attempt.
532
+ * @param sendHttp - the resolved library HTTP function.
533
+ * @returns the number of file bytes this worker delivered.
534
+ */
535
+ async function downloadWorkerOnce(
536
+ conn: WinRMParams,
537
+ remotePath: string,
538
+ start: number,
539
+ length: number,
540
+ onData: (offset: number, data: Buffer) => void,
541
+ timeoutMs: number,
542
+ authMethod: WinrmAuth,
543
+ sendHttp: ReturnType<typeof loadSendHttp> & object,
544
+ ): Promise<number> {
545
+ const base = baseParams(conn, authMethod)
546
+ const target: WsmanTarget = {
547
+ host: conn.host,
548
+ port: conn.port,
549
+ path: conn.path,
550
+ username: conn.username,
551
+ password: conn.password,
552
+ authMethod,
553
+ useHttps: conn.useHttps ?? false,
554
+ rejectUnauthorized: conn.rejectUnauthorized ?? true,
555
+ }
556
+ const hardDeadline = Date.now() + timeoutMs + SETUP_GRACE_MS
557
+ const state = { shellId: undefined as string | undefined, timedOut: false, settled: false }
558
+
559
+ const inner = async (): Promise<number> => {
560
+ const shellId = await Shell.doCreateShell(base)
561
+ state.shellId = shellId
562
+ if (state.settled) {
563
+ void Shell.doDeleteShell({ ...base, shellId }).catch(() => undefined)
564
+ throw new WinrmTimedOut()
565
+ }
566
+ const commandId = await Command.doExecuteCommand({
567
+ ...base,
568
+ shellId,
569
+ command: scriptCommandLine(psSendStream(remotePath, start, length)),
570
+ })
571
+ // Each worker decompresses its own gzip stream as it arrives, so memory
572
+ // stays bounded by one Receive response rather than by the file size. The
573
+ // error listener is attached up front: a decompression failure during the
574
+ // loop would otherwise surface as an unhandled 'error' event and kill the
575
+ // process instead of failing this transfer. `written` is only trusted once
576
+ // the inflater has ended, since 'data' fires asynchronously.
577
+ const inflater = createGunzip()
578
+ let inflateError: Error | undefined
579
+ inflater.on('error', (error: Error) => { inflateError = error })
580
+ let written = 0
581
+ let stderr = ''
582
+ inflater.on('data', (piece: Buffer) => {
583
+ onData(start + written, piece)
584
+ written += piece.length
585
+ })
586
+ let stdoutEnded = false
587
+ let exitCode: number | undefined
588
+ while (!stdoutEnded) {
589
+ // A worker that lost the deadline race keeps running in the background
590
+ // unless it checks; without this it would go on fetching and pushing
591
+ // bytes into a file the caller has already given up on.
592
+ if (state.settled || Date.now() >= hardDeadline) throw new WinrmTimedOut()
593
+ const result = await receiveOutput(target, shellId, commandId, sendHttp, DOWNLOAD_RECEIVE_TIMEOUT_MS)
594
+ let sawData = false
595
+ for (const stream of result.streams) {
596
+ if (stream.data.length === 0) {
597
+ if (stream.name === 'stdout' && stream.end) stdoutEnded = true
598
+ continue
599
+ }
600
+ sawData = true
601
+ if (stream.name === 'stdout') {
602
+ inflater.write(stream.data)
603
+ if (stream.end) stdoutEnded = true
604
+ } else {
605
+ stderr += stream.data.toString('utf8')
606
+ }
607
+ }
608
+ if (inflateError !== undefined) throw inflateError
609
+ if (result.exitCode !== undefined) exitCode = result.exitCode
610
+ // A finished command whose final response carried nothing means the
611
+ // streams are drained; without this the loop would spin on CommandState
612
+ // alone when the payload ended exactly on a response boundary.
613
+ if (!sawData && exitCode !== undefined) break
614
+ }
615
+ await new Promise<void>((resolve, reject) => {
616
+ inflater.on('error', reject)
617
+ inflater.on('end', resolve)
618
+ inflater.end()
619
+ })
620
+ if (inflateError !== undefined) throw inflateError
621
+ if (exitCode !== undefined && exitCode !== 0) {
622
+ throw new Error('streaming download failed on the target: ' + stderr.trim())
623
+ }
624
+ if (written !== length) {
625
+ throw new Error(`streaming download delivered ${written} of ${length} bytes`)
626
+ }
627
+ return written
628
+ }
629
+
630
+ try {
631
+ return await raceDeadline(inner(), hardDeadline - Date.now())
632
+ } catch (error) {
633
+ state.timedOut = error instanceof WinrmTimedOut
634
+ throw error
635
+ } finally {
636
+ state.settled = true
637
+ const shellId = state.shellId
638
+ if (shellId !== undefined) {
639
+ try {
640
+ await raceDeadline(Shell.doDeleteShell({ ...base, shellId }), DELETE_TIMEOUT_MS)
641
+ } catch {
642
+ // Best effort: an orphaned shell is reaped by the target eventually.
643
+ }
644
+ }
645
+ }
646
+ }
647
+
648
+ /**
649
+ * Stream a remote file to a consumer, in parallel byte ranges.
650
+ *
651
+ * The old path asked for 48 KiB per shell lifecycle, so a 2 MiB file cost 43
652
+ * round-trip cycles and 129 s. Measured against this target, one `Receive`
653
+ * returns at most 128 KiB no matter how large an envelope is declared, which
654
+ * makes a download latency-bound rather than bandwidth-bound; the fix is
655
+ * therefore to run several ranges at once. Each worker opens its own shell and
656
+ * streams its range gzip-compressed, and because ranges are absolute, the
657
+ * caller can place each piece by offset and completion order does not matter.
658
+ *
659
+ * The remote side compresses unconditionally: a compressible 16 MiB file
660
+ * arrives in one round trip (2.0 s), and an incompressible file was measured no
661
+ * slower than raw (9457 ms vs 9582 ms).
662
+ * @param conn - connection parameters.
663
+ * @param remotePath - file to read.
664
+ * @param onData - receives each decompressed piece with its absolute file offset.
665
+ * @param onProgress - receives cumulative bytes delivered and the total size.
666
+ * @param timeoutMs - budget for the whole download.
667
+ * @returns the number of bytes delivered, or null when streaming is unavailable.
668
+ */
669
+ export async function downloadStream(
670
+ conn: WinRMParams,
671
+ remotePath: string,
672
+ onData: (offset: number, data: Buffer) => void,
673
+ onProgress?: (transferred: number, total: number) => void,
674
+ timeoutMs = 600_000,
675
+ ): Promise<number | null> {
676
+ const sendHttp = loadSendHttp()
677
+ if (sendHttp === null) return null
678
+ const total = await remoteFileSize(conn, remotePath)
679
+ if (total === null) throw new Error(`remote file not found: ${remotePath}`)
680
+ if (total === 0) return 0
681
+ const workers = Math.max(1, Math.min(DOWNLOAD_MAX_WORKERS, Math.ceil(total / DOWNLOAD_MIN_RANGE_BYTES)))
682
+ const rangeSize = Math.ceil(total / workers)
683
+ let transferred = 0
684
+ // `Promise.all` rejects on the first worker to fail, but its siblings keep
685
+ // running until they next notice; without a gate a stale worker would keep
686
+ // feeding data from a failed attempt into the retry's counters and into the
687
+ // caller's file — after the caller has already closed it.
688
+ let generation = 0
689
+ let live = true
690
+ const candidates: WinrmAuth[] = conn.useHttps ? ['basic', 'ntlm'] : ['ntlm', 'basic']
691
+ let lastError: unknown
692
+ try {
693
+ for (const authMethod of candidates) {
694
+ // Reset per attempt: a retry re-sends every range from its own start, so
695
+ // carrying the count over would report more bytes than the file holds.
696
+ transferred = 0
697
+ const mine = ++generation
698
+ const report = (offset: number, data: Buffer): void => {
699
+ if (!live || mine !== generation) return
700
+ onData(offset, data)
701
+ transferred += data.length
702
+ onProgress?.(transferred, total)
703
+ }
704
+ try {
705
+ const ranges: { start: number; length: number }[] = []
706
+ for (let start = 0; start < total; start += rangeSize) {
707
+ ranges.push({ start, length: Math.min(rangeSize, total - start) })
708
+ }
709
+ await Promise.all(
710
+ ranges.map(range =>
711
+ downloadWorkerOnce(conn, remotePath, range.start, range.length, report, timeoutMs, authMethod, sendHttp),
712
+ ),
713
+ )
714
+ return transferred
715
+ } catch (error) {
716
+ if (error instanceof WinrmTimedOut) throw error
717
+ lastError = error
718
+ }
719
+ }
720
+ throw lastError instanceof Error ? lastError : new Error('all WinRM authentication methods failed')
721
+ } finally {
722
+ // Once this function settles the caller may close its file, so every worker
723
+ // must stop emitting immediately — an orphaned worker writing afterwards
724
+ // would hit a closed handle.
725
+ live = false
726
+ }
727
+ }