File size: 4,367 Bytes
68d7816
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
import { createWriteStream } from 'node:fs';
import { mkdir } from 'node:fs/promises';
import path from 'node:path';
import { Readable, Transform } from 'node:stream';
import { pipeline } from 'node:stream/promises';

import type { IHostProcessService } from '#/os/interface/hostProcess';

export interface CommandResult {
  readonly code: number;
  readonly stdout: string;
  readonly stderr: string;
}

async function collect(stream: Readable): Promise<string> {
  const chunks: Buffer[] = [];
  for await (const chunk of stream) {
    chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : (chunk as Buffer));
  }
  return Buffer.concat(chunks).toString('utf-8');
}

export async function runCommand(
  hostProcess: IHostProcessService,
  command: string,
  args: readonly string[],
  options: { timeout?: number } = {},
): Promise<CommandResult> {
  const spawned = await hostProcess.spawn(command, args, { windowsHide: true }).then(
    (proc) => ({ ok: true as const, proc }),
    (error: unknown) => ({ ok: false as const, error }),
  );
  if (!spawned.ok) {
    return { code: -1, stdout: '', stderr: spawned.error instanceof Error ? spawned.error.message : String(spawned.error) };
  }
  const { proc } = spawned;
  try {
    const work = Promise.all([
      collect(proc.stdout),
      collect(proc.stderr),
      proc.wait().catch(() => -1),
    ] as const);
    let timer: NodeJS.Timeout | undefined;
    const timed = options.timeout === undefined
      ? work
      : Promise.race([
          work,
          new Promise<never>((_resolve, reject) => {
            timer = setTimeout(() => {
              void proc.kill().catch(() => {});
              reject(new Error(`command timed out after ${options.timeout}ms: ${command}`));
            }, options.timeout);
            timer.unref?.();
          }),
        ]);
    try {
      const [stdout, stderr, code] = await timed;
      return { code, stdout, stderr };
    } finally {
      if (timer !== undefined) clearTimeout(timer);
    }
  } finally {
    void proc.dispose();
  }
}

export type FetchLike = (
  url: string,
  init?: { signal?: AbortSignal },
) => Promise<{
  ok: boolean;
  status: number;
  headers: { get(name: string): string | null };
  body: object | null;
}>;

export async function downloadToFile(
  url: string,
  destPath: string,
  onPercent?: (percent: number) => void,
  fetchImpl: FetchLike = fetch as unknown as FetchLike,
  options: { idleTimeoutMs?: number } = {},
): Promise<number> {
  const idleTimeoutMs = options.idleTimeoutMs ?? DOWNLOAD_IDLE_TIMEOUT_MS;
  const headerController = new AbortController();
  const headerTimer = setTimeout(() => {
    headerController.abort();
  }, idleTimeoutMs);
  headerTimer.unref?.();
  let resp;
  try {
    resp = await fetchImpl(url, { signal: headerController.signal });
  } catch (error) {
    if (error instanceof Error && (error.name === 'AbortError' || error.name === 'TimeoutError')) {
      throw new Error(`Failed to download ${url}: no response within ${idleTimeoutMs}ms`, {
        cause: error,
      });
    }
    throw error;
  } finally {
    clearTimeout(headerTimer);
  }
  if (!resp.ok || resp.body === null) {
    throw new Error(`Failed to download ${url}: HTTP ${resp.status}`);
  }
  const total = Number(resp.headers.get('content-length') ?? 0);
  await mkdir(path.dirname(destPath), { recursive: true });
  let received = 0;
  let idleTimer: NodeJS.Timeout | undefined;
  const meter = new Transform({
    transform(chunk: Buffer, _encoding, callback) {
      armIdleWatchdog();
      received += chunk.length;
      if (total > 0 && onPercent !== undefined) {
        onPercent(Math.min(99, Math.floor((received / total) * 100)));
      }
      callback(null, chunk);
    },
  });
  function armIdleWatchdog(): void {
    if (idleTimer !== undefined) clearTimeout(idleTimer);
    idleTimer = setTimeout(() => {
      meter.destroy(new Error(`Download stalled for ${idleTimeoutMs}ms: ${url}`));
    }, idleTimeoutMs);
    idleTimer.unref?.();
  }
  armIdleWatchdog();
  try {
    await pipeline(
      Readable.fromWeb(resp.body as import('node:stream/web').ReadableStream),
      meter,
      createWriteStream(destPath),
    );
  } finally {
    if (idleTimer !== undefined) clearTimeout(idleTimer);
  }
  onPercent?.(100);
  return received;
}

const DOWNLOAD_IDLE_TIMEOUT_MS = 30_000;