@vpalmisano/webrtcperf 4.0.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.
- package/LICENSE +661 -0
- package/README.md +296 -0
- package/app.min.js +2 -0
- package/build/src/app.d.ts +6 -0
- package/build/src/app.js +207 -0
- package/build/src/app.js.map +1 -0
- package/build/src/config.d.ts +104 -0
- package/build/src/config.js +880 -0
- package/build/src/config.js.map +1 -0
- package/build/src/generate-config-docs.d.ts +1 -0
- package/build/src/generate-config-docs.js +41 -0
- package/build/src/generate-config-docs.js.map +1 -0
- package/build/src/index.d.ts +9 -0
- package/build/src/index.js +26 -0
- package/build/src/index.js.map +1 -0
- package/build/src/media.d.ts +33 -0
- package/build/src/media.js +113 -0
- package/build/src/media.js.map +1 -0
- package/build/src/rtcstats.d.ts +302 -0
- package/build/src/rtcstats.js +418 -0
- package/build/src/rtcstats.js.map +1 -0
- package/build/src/server.d.ts +173 -0
- package/build/src/server.js +639 -0
- package/build/src/server.js.map +1 -0
- package/build/src/session.d.ts +277 -0
- package/build/src/session.js +1552 -0
- package/build/src/session.js.map +1 -0
- package/build/src/stats.d.ts +243 -0
- package/build/src/stats.js +1383 -0
- package/build/src/stats.js.map +1 -0
- package/build/src/utils.d.ts +249 -0
- package/build/src/utils.js +1220 -0
- package/build/src/utils.js.map +1 -0
- package/build/src/visqol.d.ts +6 -0
- package/build/src/visqol.js +61 -0
- package/build/src/visqol.js.map +1 -0
- package/build/src/vmaf.d.ts +83 -0
- package/build/src/vmaf.js +624 -0
- package/build/src/vmaf.js.map +1 -0
- package/build/tsconfig.tsbuildinfo +1 -0
- package/package.json +129 -0
- package/src/app.ts +241 -0
- package/src/config.ts +852 -0
- package/src/generate-config-docs.ts +47 -0
- package/src/index.ts +9 -0
- package/src/media.ts +151 -0
- package/src/rtcstats.ts +507 -0
- package/src/server.ts +645 -0
- package/src/session.ts +1908 -0
- package/src/stats.ts +1668 -0
- package/src/utils.ts +1295 -0
- package/src/visqol.ts +62 -0
- package/src/vmaf.ts +771 -0
package/src/utils.ts
ADDED
|
@@ -0,0 +1,1295 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Browser,
|
|
3
|
+
computeExecutablePath,
|
|
4
|
+
getInstalledBrowsers,
|
|
5
|
+
getVersionComparator,
|
|
6
|
+
install,
|
|
7
|
+
} from '@puppeteer/browsers'
|
|
8
|
+
import { spawn } from 'child_process'
|
|
9
|
+
import axios from 'axios'
|
|
10
|
+
import { createHash } from 'crypto'
|
|
11
|
+
import * as dns from 'dns'
|
|
12
|
+
import FormData from 'form-data'
|
|
13
|
+
import fs, { WriteStream, createWriteStream } from 'fs'
|
|
14
|
+
import { Agent } from 'https'
|
|
15
|
+
import * as ipaddrJs from 'ipaddr.js'
|
|
16
|
+
import net from 'net'
|
|
17
|
+
import NodeCache from 'node-cache'
|
|
18
|
+
import * as OSUtils from 'node-os-utils'
|
|
19
|
+
import os, { networkInterfaces } from 'os'
|
|
20
|
+
import path, { dirname } from 'path'
|
|
21
|
+
import pidtree from 'pidtree'
|
|
22
|
+
import pidusage from 'pidusage'
|
|
23
|
+
import puppeteer, { Page } from 'puppeteer-core'
|
|
24
|
+
|
|
25
|
+
import { Session } from './session'
|
|
26
|
+
|
|
27
|
+
// eslint-disable-next-line
|
|
28
|
+
const ps = require('pidusage/lib/ps')
|
|
29
|
+
|
|
30
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
31
|
+
export const { Log } = require('debug-level')
|
|
32
|
+
|
|
33
|
+
interface Logger {
|
|
34
|
+
error: (...args: unknown[]) => void
|
|
35
|
+
warn: (...args: unknown[]) => void
|
|
36
|
+
info: (...args: unknown[]) => void
|
|
37
|
+
debug: (...args: unknown[]) => void
|
|
38
|
+
log: (...args: unknown[]) => void
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function logger(name: string, options = {}): Logger {
|
|
42
|
+
return new Log(name, { splitLine: false, ...options })
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export class LoggerInterface {
|
|
46
|
+
name?: string
|
|
47
|
+
|
|
48
|
+
private logInit(args: unknown[]): void {
|
|
49
|
+
if (this.name) {
|
|
50
|
+
args.unshift(`[${this.name}]`)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
debug(...args: unknown[]): void {
|
|
55
|
+
this.logInit(args)
|
|
56
|
+
log.debug(...args)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
info(...args: unknown[]): void {
|
|
60
|
+
this.logInit(args)
|
|
61
|
+
log.info(...args)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
warn(...args: unknown[]): void {
|
|
65
|
+
this.logInit(args)
|
|
66
|
+
log.warn(...args)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
error(...args: unknown[]): void {
|
|
70
|
+
this.logInit(args)
|
|
71
|
+
log.error(...args)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
log(...args: unknown[]): void {
|
|
75
|
+
this.logInit(args)
|
|
76
|
+
log.log(...args)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const log = logger('webrtcperf:utils')
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Resolves the absolute path from the package installation directory.
|
|
84
|
+
* @param relativePath The relative path.
|
|
85
|
+
* @returns The absolute path.
|
|
86
|
+
*/
|
|
87
|
+
export function resolvePackagePath(relativePath: string): string {
|
|
88
|
+
if ('__nexe' in process) {
|
|
89
|
+
return relativePath
|
|
90
|
+
}
|
|
91
|
+
if (process.env.WEBPACK) {
|
|
92
|
+
return path.join(path.dirname(__filename), relativePath)
|
|
93
|
+
}
|
|
94
|
+
const libPath = require.resolve(relativePath)
|
|
95
|
+
if (fs.existsSync(libPath)) {
|
|
96
|
+
return libPath
|
|
97
|
+
}
|
|
98
|
+
for (const d of ['..', '../..']) {
|
|
99
|
+
const p = path.join(__dirname, d, relativePath)
|
|
100
|
+
if (fs.existsSync(p)) {
|
|
101
|
+
return require.resolve(p)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
throw new Error(`resolvePackagePath: ${relativePath} not found`)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Calculates the sha256 sum.
|
|
109
|
+
* @param data The string input
|
|
110
|
+
*/
|
|
111
|
+
export function sha256(data: string): string {
|
|
112
|
+
return createHash('sha256').update(data).digest('hex')
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const ProcessStatsCache = new NodeCache({ stdTTL: 5, checkperiod: 10 })
|
|
116
|
+
const ProcessChildrenCache = new NodeCache({ stdTTL: 15, checkperiod: 15 })
|
|
117
|
+
|
|
118
|
+
interface ProcessStat {
|
|
119
|
+
cpu: number
|
|
120
|
+
memory: number
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Returns the process stats.
|
|
125
|
+
* @param pid The process pid
|
|
126
|
+
* @param children If process children should be taken into account.
|
|
127
|
+
* @returns
|
|
128
|
+
*/
|
|
129
|
+
export async function getProcessStats(pid = 0, children = false): Promise<ProcessStat> {
|
|
130
|
+
const processPid = pid || process.pid
|
|
131
|
+
let stat: ProcessStat | undefined = ProcessStatsCache.get(processPid)
|
|
132
|
+
if (stat) {
|
|
133
|
+
return stat
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const pidStats = await pidusage(processPid)
|
|
137
|
+
if (pidStats) {
|
|
138
|
+
stat = {
|
|
139
|
+
cpu: pidStats.cpu,
|
|
140
|
+
memory: pidStats.memory / 1e6,
|
|
141
|
+
}
|
|
142
|
+
} else {
|
|
143
|
+
stat = { cpu: 0, memory: 0 }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (children) {
|
|
147
|
+
try {
|
|
148
|
+
let childrenPids: number[] | undefined = ProcessChildrenCache.get(processPid)
|
|
149
|
+
if (!childrenPids?.length) {
|
|
150
|
+
childrenPids = await pidtree(processPid)
|
|
151
|
+
if (childrenPids?.length) {
|
|
152
|
+
ProcessChildrenCache.set(processPid, childrenPids)
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
if (childrenPids?.length) {
|
|
156
|
+
const pidStats = await pidusage(childrenPids)
|
|
157
|
+
for (const p of childrenPids) {
|
|
158
|
+
if (pidStats[p]) {
|
|
159
|
+
stat.cpu += pidStats[p].cpu
|
|
160
|
+
stat.memory += pidStats[p].memory / 1e6
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
} catch (err) {
|
|
165
|
+
log.error(`getProcessStats children error: ${(err as Error).stack}`)
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
ProcessStatsCache.set(processPid, stat)
|
|
169
|
+
return stat
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Socket stats.
|
|
173
|
+
interface SocketStat {
|
|
174
|
+
recvBytes: number
|
|
175
|
+
sendBytes: number
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export async function getSocketStats(processPid: number): Promise<SocketStat> {
|
|
179
|
+
const stats: SocketStat = { recvBytes: 0, sendBytes: 0 }
|
|
180
|
+
try {
|
|
181
|
+
const { stdout } = await runShellCommand(`ss -nOHpti | { grep pid=${processPid} || true; }`)
|
|
182
|
+
for (const { groups } of stdout.matchAll(/bytes_sent:(?<sendBytes>\d+).+bytes_received:(?<recvBytes>\d+)/g)) {
|
|
183
|
+
if (!groups) continue
|
|
184
|
+
const recvBytes = parseInt(groups.recvBytes)
|
|
185
|
+
const sendBytes = parseInt(groups.sendBytes)
|
|
186
|
+
stats.recvBytes += recvBytes
|
|
187
|
+
stats.sendBytes += sendBytes
|
|
188
|
+
}
|
|
189
|
+
} catch (err) {
|
|
190
|
+
log.error(`socketStats error: ${(err as Error).stack}`)
|
|
191
|
+
}
|
|
192
|
+
return stats
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// System stats.
|
|
196
|
+
const SystemStatsCache = new NodeCache({ stdTTL: 30, checkperiod: 60 })
|
|
197
|
+
|
|
198
|
+
export interface SystemStats {
|
|
199
|
+
usedCpu: number
|
|
200
|
+
usedMemory: number
|
|
201
|
+
usedGpu: number
|
|
202
|
+
usedGpuMemory: number
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function updateSystemStats(): Promise<void> {
|
|
206
|
+
const [cpu, memInfo, gpuStats] = await Promise.all([OSUtils.cpu.free(10000), OSUtils.mem.info(), systemGpuStats()])
|
|
207
|
+
const stat = {
|
|
208
|
+
usedCpu: 100 - cpu,
|
|
209
|
+
usedMemory: 100 - memInfo.freeMemPercentage,
|
|
210
|
+
usedGpu: gpuStats.gpu,
|
|
211
|
+
usedGpuMemory: gpuStats.mem,
|
|
212
|
+
}
|
|
213
|
+
SystemStatsCache.set('default', stat)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
let systemStatsInterval: NodeJS.Timeout | null = null
|
|
217
|
+
|
|
218
|
+
export function getSystemStats(): SystemStats | undefined {
|
|
219
|
+
if (!systemStatsInterval) {
|
|
220
|
+
startUpdateSystemStats()
|
|
221
|
+
}
|
|
222
|
+
return SystemStatsCache.get<SystemStats>('default')
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export function startUpdateSystemStats(): void {
|
|
226
|
+
if (systemStatsInterval) {
|
|
227
|
+
return
|
|
228
|
+
}
|
|
229
|
+
systemStatsInterval = setInterval(updateSystemStats, 5000)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export function stopTimers(): void {
|
|
233
|
+
if (systemStatsInterval) {
|
|
234
|
+
clearInterval(systemStatsInterval)
|
|
235
|
+
systemStatsInterval = null
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Sleeps for the specified amount of time.
|
|
241
|
+
* @param ms
|
|
242
|
+
*/
|
|
243
|
+
export function sleep(ms: number): Promise<void> {
|
|
244
|
+
return new Promise(resolve => setTimeout(() => resolve(), ms))
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
declare global {
|
|
248
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
249
|
+
let getActiveAudioTracks: () => any[]
|
|
250
|
+
let publisherSetMuted: (muted: boolean) => Promise<void>
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
let randomActivateAudioTimeoutId: NodeJS.Timeout | null = null
|
|
254
|
+
let randomActivateAudioRunning = false
|
|
255
|
+
|
|
256
|
+
export function startRandomActivateAudio(
|
|
257
|
+
sessions: Map<number, Session>,
|
|
258
|
+
randomAudioPeriod: number,
|
|
259
|
+
randomAudioProbability: number,
|
|
260
|
+
randomAudioRange: number,
|
|
261
|
+
): void {
|
|
262
|
+
if (randomActivateAudioRunning) return
|
|
263
|
+
randomActivateAudioRunning = true
|
|
264
|
+
void randomActivateAudio(sessions, randomAudioPeriod, randomAudioProbability, randomAudioRange)
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export function stopRandomActivateAudio(): void {
|
|
268
|
+
randomActivateAudioRunning = false
|
|
269
|
+
if (randomActivateAudioTimeoutId) clearTimeout(randomActivateAudioTimeoutId)
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Randomly activate audio from one tab at time.
|
|
274
|
+
* @param sessions The sessions Map
|
|
275
|
+
* @param randomAudioPeriod If set, the function will be called in loop
|
|
276
|
+
* @param randomAudioProbability The activation probability
|
|
277
|
+
* @param randomAudioRange The number of pages to include into the automation
|
|
278
|
+
*/
|
|
279
|
+
export async function randomActivateAudio(
|
|
280
|
+
sessions: Map<number, Session>,
|
|
281
|
+
randomAudioPeriod: number,
|
|
282
|
+
randomAudioProbability: number,
|
|
283
|
+
randomAudioRange: number,
|
|
284
|
+
): Promise<void> {
|
|
285
|
+
if (!randomAudioPeriod || !randomActivateAudioRunning) {
|
|
286
|
+
return
|
|
287
|
+
}
|
|
288
|
+
try {
|
|
289
|
+
let pages: (Page | null)[] = []
|
|
290
|
+
for (const session of sessions.values()) {
|
|
291
|
+
const sessionPages = [...session.pages.values()]
|
|
292
|
+
if (randomAudioRange) {
|
|
293
|
+
if (session.id > randomAudioRange) {
|
|
294
|
+
break
|
|
295
|
+
}
|
|
296
|
+
sessionPages.splice(randomAudioRange - session.id)
|
|
297
|
+
}
|
|
298
|
+
pages = pages.concat(sessionPages)
|
|
299
|
+
}
|
|
300
|
+
// Remove pages with no audio tracks.
|
|
301
|
+
for (const [i, page] of pages.entries()) {
|
|
302
|
+
if (!page) {
|
|
303
|
+
continue
|
|
304
|
+
}
|
|
305
|
+
let active = 0
|
|
306
|
+
try {
|
|
307
|
+
active = await page.evaluate(() => getActiveAudioTracks().length)
|
|
308
|
+
} catch (err) {
|
|
309
|
+
log.error(`randomActivateAudio error: ${(err as Error).stack}`)
|
|
310
|
+
}
|
|
311
|
+
if (!active) {
|
|
312
|
+
pages[i] = null
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
const pagesWithAudio: Page[] = pages.filter(p => !!p)
|
|
316
|
+
//
|
|
317
|
+
const index = Math.floor(Math.random() * pagesWithAudio.length)
|
|
318
|
+
const enable = Math.round(100 * Math.random()) <= randomAudioProbability
|
|
319
|
+
log.debug('randomActivateAudio %j', {
|
|
320
|
+
pages: pagesWithAudio.length,
|
|
321
|
+
randomAudioProbability,
|
|
322
|
+
index,
|
|
323
|
+
enable,
|
|
324
|
+
})
|
|
325
|
+
for (const [i, page] of pagesWithAudio.entries()) {
|
|
326
|
+
try {
|
|
327
|
+
if (i === index) {
|
|
328
|
+
log.debug(`Changing audio in page ${i + 1}/${pagesWithAudio.length} (enable: ${enable})`)
|
|
329
|
+
await page.evaluate(async enable => {
|
|
330
|
+
if (typeof publisherSetMuted !== 'undefined') {
|
|
331
|
+
await publisherSetMuted(!enable)
|
|
332
|
+
} else {
|
|
333
|
+
getActiveAudioTracks().forEach(track => {
|
|
334
|
+
track.enabled = enable
|
|
335
|
+
// track.dispatchEvent(new Event('custom-enabled'));
|
|
336
|
+
})
|
|
337
|
+
}
|
|
338
|
+
}, enable)
|
|
339
|
+
} else {
|
|
340
|
+
await page.evaluate(async () => {
|
|
341
|
+
if (typeof publisherSetMuted !== 'undefined') {
|
|
342
|
+
await publisherSetMuted(true)
|
|
343
|
+
} else {
|
|
344
|
+
getActiveAudioTracks().forEach(track => {
|
|
345
|
+
track.enabled = false
|
|
346
|
+
// track.dispatchEvent(new Event('custom-enabled'));
|
|
347
|
+
})
|
|
348
|
+
}
|
|
349
|
+
})
|
|
350
|
+
}
|
|
351
|
+
} catch (err) {
|
|
352
|
+
log.error(`randomActivateAudio in page ${i + 1}/${pagesWithAudio.length} error: ${(err as Error).stack}`)
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
} catch (err) {
|
|
356
|
+
log.error(`randomActivateAudio error: ${(err as Error).stack}`)
|
|
357
|
+
} finally {
|
|
358
|
+
if (randomActivateAudioRunning) {
|
|
359
|
+
const nextTime = randomAudioPeriod * (1 + Math.random())
|
|
360
|
+
if (randomActivateAudioTimeoutId) clearTimeout(randomActivateAudioTimeoutId)
|
|
361
|
+
randomActivateAudioTimeoutId = setTimeout(
|
|
362
|
+
randomActivateAudio,
|
|
363
|
+
nextTime * 1000,
|
|
364
|
+
sessions,
|
|
365
|
+
randomAudioPeriod,
|
|
366
|
+
randomAudioProbability,
|
|
367
|
+
randomAudioRange,
|
|
368
|
+
)
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* The {@link downloadUrl} output.
|
|
375
|
+
*/
|
|
376
|
+
export interface DownloadData {
|
|
377
|
+
/** Download data. */
|
|
378
|
+
data: string
|
|
379
|
+
/** Start byte range. */
|
|
380
|
+
start: number
|
|
381
|
+
/** End byte range. */
|
|
382
|
+
end: number
|
|
383
|
+
/** Total returned size. */
|
|
384
|
+
total: number
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Downloads the specified `url` to a local file or returning the file content
|
|
389
|
+
* as {@link DownloadData} object.
|
|
390
|
+
* @param url The remote url to download.
|
|
391
|
+
* @param auth The basic authentication (`user:password`).
|
|
392
|
+
* @param outputLocationPath The file output. If not specified, the download
|
|
393
|
+
* content will be returned as {@link DownloadData} instance.
|
|
394
|
+
* @param range The HTTP byte range to download (e.g. `10-100`).
|
|
395
|
+
* @param timeout The download timeout in milliseconds.
|
|
396
|
+
*/
|
|
397
|
+
export async function downloadUrl(
|
|
398
|
+
url: string,
|
|
399
|
+
auth?: string,
|
|
400
|
+
outputLocationPath?: string,
|
|
401
|
+
range?: string,
|
|
402
|
+
timeout = 60000,
|
|
403
|
+
): Promise<void | DownloadData> {
|
|
404
|
+
log.debug(`downloadUrl url=${url} ${outputLocationPath}`)
|
|
405
|
+
const authParts = auth?.split(':')
|
|
406
|
+
let writer: WriteStream | null = null
|
|
407
|
+
if (outputLocationPath) {
|
|
408
|
+
await fs.promises.mkdir(dirname(outputLocationPath), {
|
|
409
|
+
recursive: true,
|
|
410
|
+
})
|
|
411
|
+
writer = createWriteStream(outputLocationPath)
|
|
412
|
+
}
|
|
413
|
+
const response = await axios({
|
|
414
|
+
method: 'get',
|
|
415
|
+
url,
|
|
416
|
+
auth: authParts
|
|
417
|
+
? {
|
|
418
|
+
username: authParts[0],
|
|
419
|
+
password: authParts[1],
|
|
420
|
+
}
|
|
421
|
+
: undefined,
|
|
422
|
+
headers: range
|
|
423
|
+
? {
|
|
424
|
+
Range: `bytes=${range}`,
|
|
425
|
+
}
|
|
426
|
+
: undefined,
|
|
427
|
+
timeout,
|
|
428
|
+
onDownloadProgress: event => {
|
|
429
|
+
log.debug(`downloadUrl fileUrl=${url} progress=${event.progress || event.bytes}`)
|
|
430
|
+
},
|
|
431
|
+
httpsAgent: new Agent({
|
|
432
|
+
rejectUnauthorized: false,
|
|
433
|
+
}),
|
|
434
|
+
responseType: writer ? 'stream' : 'text',
|
|
435
|
+
})
|
|
436
|
+
if (writer) {
|
|
437
|
+
return new Promise((resolve, reject) => {
|
|
438
|
+
if (!writer) {
|
|
439
|
+
return
|
|
440
|
+
}
|
|
441
|
+
response.data.pipe(writer)
|
|
442
|
+
let error: Error | null = null
|
|
443
|
+
writer.once('error', err => {
|
|
444
|
+
error = err
|
|
445
|
+
if (writer) writer.close()
|
|
446
|
+
reject(err)
|
|
447
|
+
})
|
|
448
|
+
writer.once('close', () => {
|
|
449
|
+
if (!error) {
|
|
450
|
+
resolve()
|
|
451
|
+
}
|
|
452
|
+
})
|
|
453
|
+
})
|
|
454
|
+
} else {
|
|
455
|
+
/* log.debug(`downloadUrl ${response.data.length} bytes, headers=${
|
|
456
|
+
JSON.stringify(response.headers)}`); */
|
|
457
|
+
let start = 0
|
|
458
|
+
let end = 0
|
|
459
|
+
let total = 0
|
|
460
|
+
if (response.headers['content-range']) {
|
|
461
|
+
log.debug(`downloadUrl ${response.data.length} bytes, content-range=${response.headers['content-range']}`)
|
|
462
|
+
const contentRange = response.headers['content-range'].split('/')
|
|
463
|
+
const rangeParts = contentRange[0].split('-')
|
|
464
|
+
total = parseInt(contentRange[1])
|
|
465
|
+
if (rangeParts.length === 2) {
|
|
466
|
+
start = parseInt(rangeParts[0])
|
|
467
|
+
end = parseInt(rangeParts[1])
|
|
468
|
+
} else if (contentRange[0].startsWith('-')) {
|
|
469
|
+
end = parseInt(rangeParts[0])
|
|
470
|
+
} else if (contentRange[0].endsWith('-')) {
|
|
471
|
+
start = parseInt(rangeParts[0])
|
|
472
|
+
end = total
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
return {
|
|
476
|
+
data: response.data,
|
|
477
|
+
start,
|
|
478
|
+
end,
|
|
479
|
+
total,
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Uploads the file to the specified `url`.
|
|
486
|
+
* @param filePath The file path to upload.
|
|
487
|
+
* @param url The remote url to upload.
|
|
488
|
+
* @param auth The basic authentication (`user:password`).
|
|
489
|
+
*/
|
|
490
|
+
export async function uploadUrl(filePath: string, url: string, auth?: string): Promise<string> {
|
|
491
|
+
log.debug(`uploadUrl ${filePath} to ${url}`)
|
|
492
|
+
const authParts = auth?.split(':')
|
|
493
|
+
const formData = new FormData()
|
|
494
|
+
formData.append('file', fs.createReadStream(filePath))
|
|
495
|
+
const response = await axios({
|
|
496
|
+
method: 'post',
|
|
497
|
+
url,
|
|
498
|
+
auth: authParts
|
|
499
|
+
? {
|
|
500
|
+
username: authParts[0],
|
|
501
|
+
password: authParts[1],
|
|
502
|
+
}
|
|
503
|
+
: undefined,
|
|
504
|
+
headers: formData.getHeaders(),
|
|
505
|
+
timeout: 3600 * 1000,
|
|
506
|
+
httpsAgent: new Agent({
|
|
507
|
+
rejectUnauthorized: false,
|
|
508
|
+
}),
|
|
509
|
+
responseType: 'text',
|
|
510
|
+
data: formData,
|
|
511
|
+
})
|
|
512
|
+
return response.data as string
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
const HideAuthRegExp = new RegExp('(http[s]{0,1}://)(.+?:.+?@)', 'g')
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* Hides the authentication part from an HTTP url.
|
|
519
|
+
* @param data
|
|
520
|
+
*/
|
|
521
|
+
export function hideAuth(data: string): string {
|
|
522
|
+
if (!data) {
|
|
523
|
+
return data
|
|
524
|
+
}
|
|
525
|
+
return data.replace(HideAuthRegExp, '$1')
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/** Exit handler callback. */
|
|
529
|
+
export type ExitHandler = (signal?: string) => Promise<void>
|
|
530
|
+
|
|
531
|
+
const exitHandlers = new Set<ExitHandler>()
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* Register an {@link ExitHandler} callback that will be executed at the
|
|
535
|
+
* nodejs process exit.
|
|
536
|
+
* @param exitHandler
|
|
537
|
+
*/
|
|
538
|
+
export function registerExitHandler(exitHandler: ExitHandler): void {
|
|
539
|
+
exitHandlers.add(exitHandler)
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Un-registers the {@link ExitHandler} callback.
|
|
544
|
+
* @param exitHandler
|
|
545
|
+
*/
|
|
546
|
+
export function unregisterExitHandler(exitHandler: ExitHandler): void {
|
|
547
|
+
exitHandlers.delete(exitHandler)
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
const runExitHandlers = async (signal?: string): Promise<void> => {
|
|
551
|
+
let i = 0
|
|
552
|
+
for (const exitHandler of exitHandlers.values()) {
|
|
553
|
+
const id = `${i + 1}/${exitHandlers.size}`
|
|
554
|
+
log.debug(`running exitHandler ${id}`)
|
|
555
|
+
try {
|
|
556
|
+
await exitHandler(signal)
|
|
557
|
+
log.debug(` exitHandler ${id} done`)
|
|
558
|
+
} catch (err) {
|
|
559
|
+
log.error(`exitHandler ${id} error: ${err}`)
|
|
560
|
+
}
|
|
561
|
+
i++
|
|
562
|
+
}
|
|
563
|
+
exitHandlers.clear()
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
let runExitHandlersPromise: Promise<void> | null = null
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* Runs the registered exit handlers immediately.
|
|
570
|
+
* @param signal The process exit signal.
|
|
571
|
+
*/
|
|
572
|
+
export async function runExitHandlersNow(signal?: string): Promise<void> {
|
|
573
|
+
if (!runExitHandlersPromise) {
|
|
574
|
+
runExitHandlersPromise = runExitHandlers(signal)
|
|
575
|
+
}
|
|
576
|
+
await runExitHandlersPromise
|
|
577
|
+
stopTimers()
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
const SIGNALS = [
|
|
581
|
+
'beforeExit',
|
|
582
|
+
'uncaughtException',
|
|
583
|
+
'unhandledRejection',
|
|
584
|
+
'SIGHUP',
|
|
585
|
+
'SIGINT',
|
|
586
|
+
'SIGQUIT',
|
|
587
|
+
'SIGILL',
|
|
588
|
+
'SIGTRAP',
|
|
589
|
+
'SIGABRT',
|
|
590
|
+
'SIGBUS',
|
|
591
|
+
'SIGFPE',
|
|
592
|
+
'SIGUSR1',
|
|
593
|
+
'SIGSEGV',
|
|
594
|
+
'SIGUSR2',
|
|
595
|
+
'SIGTERM',
|
|
596
|
+
]
|
|
597
|
+
process.setMaxListeners(process.getMaxListeners() + SIGNALS.length)
|
|
598
|
+
SIGNALS.forEach(event =>
|
|
599
|
+
process.once(event, async signal => {
|
|
600
|
+
if (signal instanceof Error) {
|
|
601
|
+
log.error(`Exit on error: ${signal.stack || signal.message}`)
|
|
602
|
+
} else {
|
|
603
|
+
log.debug(`Exit on signal: ${signal}`)
|
|
604
|
+
}
|
|
605
|
+
await runExitHandlersNow(signal)
|
|
606
|
+
process.exit(0)
|
|
607
|
+
}),
|
|
608
|
+
)
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* Downloads the configured chrome executable if it doesn't exists into the
|
|
612
|
+
* `$HOME/.webrtcperf/chrome` directory.
|
|
613
|
+
* @returns The revision info.
|
|
614
|
+
*/
|
|
615
|
+
export async function checkChromeExecutable(): Promise<string> {
|
|
616
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
617
|
+
const { loadConfig } = require('./config')
|
|
618
|
+
const config = loadConfig()
|
|
619
|
+
const cacheDir = path.join(os.homedir(), '.webrtcperf/chrome')
|
|
620
|
+
|
|
621
|
+
const fixSemVer = (v: string) => v.split('.').slice(0, 3).join('.')
|
|
622
|
+
|
|
623
|
+
const browsers = await getInstalledBrowsers({ cacheDir })
|
|
624
|
+
const revisions = browsers.map(b => fixSemVer(b.buildId))
|
|
625
|
+
const browser = Browser.CHROME
|
|
626
|
+
revisions.sort(getVersionComparator(browser))
|
|
627
|
+
log.debug(`Available chrome versions: ${revisions}`)
|
|
628
|
+
const requiredRevision = config.chromiumVersion
|
|
629
|
+
if (!requiredRevision) throw new Error('Chromium version not set')
|
|
630
|
+
if (!revisions.includes(fixSemVer(requiredRevision))) {
|
|
631
|
+
log.info(`Downloading chrome ${requiredRevision}...`)
|
|
632
|
+
let progress = 0
|
|
633
|
+
await install({
|
|
634
|
+
browser,
|
|
635
|
+
buildId: requiredRevision,
|
|
636
|
+
cacheDir,
|
|
637
|
+
downloadProgressCallback: (downloadedBytes, totalBytes) => {
|
|
638
|
+
const cur = Math.round((100 * downloadedBytes) / totalBytes)
|
|
639
|
+
if (cur - progress > 1) {
|
|
640
|
+
progress = cur
|
|
641
|
+
log.info(` ${progress}%`)
|
|
642
|
+
}
|
|
643
|
+
},
|
|
644
|
+
})
|
|
645
|
+
log.info(`Downloading chrome ${requiredRevision} done.`)
|
|
646
|
+
}
|
|
647
|
+
return computeExecutablePath({
|
|
648
|
+
browser,
|
|
649
|
+
cacheDir,
|
|
650
|
+
buildId: requiredRevision,
|
|
651
|
+
})
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
export function clampMinMax(value: number, min: number, max: number): number {
|
|
655
|
+
return Math.max(Math.min(value, max), min)
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/** Runs the shell command asynchronously. */
|
|
659
|
+
export async function runShellCommand(
|
|
660
|
+
cmd: string,
|
|
661
|
+
verbose = false,
|
|
662
|
+
maxBuffer = 1024 * 1024,
|
|
663
|
+
): Promise<{ stdout: string; stderr: string }> {
|
|
664
|
+
if (verbose) log.debug(`runShellCommand cmd: ${cmd}`)
|
|
665
|
+
return new Promise((resolve, reject) => {
|
|
666
|
+
const p = spawn(cmd, {
|
|
667
|
+
shell: true,
|
|
668
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
669
|
+
detached: true,
|
|
670
|
+
})
|
|
671
|
+
let stdout = ''
|
|
672
|
+
let stderr = ''
|
|
673
|
+
p.stdout.on('data', data => {
|
|
674
|
+
if (maxBuffer && stdout.length > maxBuffer) {
|
|
675
|
+
stdout = stdout.slice(data.length)
|
|
676
|
+
}
|
|
677
|
+
stdout += data
|
|
678
|
+
})
|
|
679
|
+
p.stderr.on('data', data => {
|
|
680
|
+
if (maxBuffer && stderr.length > maxBuffer) {
|
|
681
|
+
stderr = stderr.slice(data.length)
|
|
682
|
+
}
|
|
683
|
+
stderr += data
|
|
684
|
+
})
|
|
685
|
+
p.once('error', err => reject(err))
|
|
686
|
+
p.once('close', code => {
|
|
687
|
+
if (code !== 0) {
|
|
688
|
+
reject(new Error(`runShellCommand cmd: ${cmd} failed with code ${code}: ${stderr}`))
|
|
689
|
+
} else {
|
|
690
|
+
if (verbose) log.debug(`runShellCommand cmd: ${cmd} done`, { stdout, stderr })
|
|
691
|
+
resolve({ stdout, stderr })
|
|
692
|
+
}
|
|
693
|
+
})
|
|
694
|
+
})
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
//
|
|
698
|
+
const ipCache = new Map<string, { host: string; timestamp: number }>()
|
|
699
|
+
|
|
700
|
+
/**
|
|
701
|
+
* Resolves the IP address hostname.
|
|
702
|
+
* @param ip The IP address.
|
|
703
|
+
* @param cacheTime The number of milliseconds to keep the resolved hostname
|
|
704
|
+
* into the memory cache.
|
|
705
|
+
* @returns The IP address hostname.
|
|
706
|
+
*/
|
|
707
|
+
export async function resolveIP(ip: string, cacheTime = 60 * 60 * 1000): Promise<string> {
|
|
708
|
+
if (!ip) return ''
|
|
709
|
+
if (ipaddrJs.parse(ip).range() === 'private') {
|
|
710
|
+
return ip
|
|
711
|
+
}
|
|
712
|
+
const timestamp = Date.now()
|
|
713
|
+
const ret = ipCache.get(ip)
|
|
714
|
+
if (!ret || timestamp - ret.timestamp > cacheTime) {
|
|
715
|
+
const host = await Promise.race([
|
|
716
|
+
sleep(1000),
|
|
717
|
+
dns.promises
|
|
718
|
+
.reverse(ip)
|
|
719
|
+
.then(hosts => {
|
|
720
|
+
if (hosts.length) {
|
|
721
|
+
log.debug(`resolveIP ${ip} -> ${hosts.join(', ')}`)
|
|
722
|
+
ipCache.set(ip, {
|
|
723
|
+
host: hosts[0],
|
|
724
|
+
// Keep the value for 10 min.
|
|
725
|
+
timestamp: timestamp + 10 * cacheTime,
|
|
726
|
+
})
|
|
727
|
+
return hosts[0]
|
|
728
|
+
} else {
|
|
729
|
+
ipCache.set(ip, { host: ip, timestamp })
|
|
730
|
+
return ip
|
|
731
|
+
}
|
|
732
|
+
})
|
|
733
|
+
.catch(err => {
|
|
734
|
+
log.debug(`resolveIP error: ${(err as Error).stack}`)
|
|
735
|
+
ipCache.set(ip, { host: ip, timestamp })
|
|
736
|
+
}),
|
|
737
|
+
])
|
|
738
|
+
return host || ip
|
|
739
|
+
}
|
|
740
|
+
return ret?.host || ip
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
/**
|
|
744
|
+
* Strips the console logs characters from the provided string.
|
|
745
|
+
* @param str The input string.
|
|
746
|
+
* @returns The strippped string.
|
|
747
|
+
*/
|
|
748
|
+
export function stripColors(str: string): string {
|
|
749
|
+
// eslint-disable-next-line no-control-regex
|
|
750
|
+
return str.replace(/\x1B[[(?);]{0,2}(;?\d)*./g, '')
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
const nvidiaGpuPresent = fs.existsSync('/usr/bin/nvidia-smi') && fs.existsSync('/dev/dri')
|
|
754
|
+
|
|
755
|
+
const macOS = process.platform === 'darwin' && fs.existsSync('/usr/sbin/ioreg')
|
|
756
|
+
|
|
757
|
+
/**
|
|
758
|
+
* Returns the GPU usage.
|
|
759
|
+
*
|
|
760
|
+
* On Linux, the `nvidia-smi` should be installed if Nvidia card is used.
|
|
761
|
+
* @returns The GPU usage.
|
|
762
|
+
*/
|
|
763
|
+
export async function systemGpuStats(): Promise<{ gpu: number; mem: number }> {
|
|
764
|
+
try {
|
|
765
|
+
if (nvidiaGpuPresent) {
|
|
766
|
+
const { stdout } = await runShellCommand('nvidia-smi --query-gpu=utilization.gpu,utilization.memory --format=csv')
|
|
767
|
+
const line = stdout.split('\n')[1].trim()
|
|
768
|
+
const [gpu, mem] = line.split(',').map(s => parseFloat(s.replace(' %', '')))
|
|
769
|
+
return { gpu, mem }
|
|
770
|
+
} else if (macOS) {
|
|
771
|
+
const { stdout } = await runShellCommand('ioreg -r -d 1 -w 0 -c IOAccelerator | grep PerformanceStatistics\\"')
|
|
772
|
+
const stats = JSON.parse(stdout.trim().split(' = ')[1].replace(/=/g, ':'))
|
|
773
|
+
const gpu = stats['Device Utilization %'] || stats['GPU Activity(%)'] || 0
|
|
774
|
+
return { gpu, mem: 0 }
|
|
775
|
+
}
|
|
776
|
+
} catch (err) {
|
|
777
|
+
log.debug(`systemGpuStats error: ${(err as Error).stack}`)
|
|
778
|
+
}
|
|
779
|
+
return { gpu: 0, mem: 0 }
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
/**
|
|
783
|
+
* Schedules a function call at the specified time interval.
|
|
784
|
+
*/
|
|
785
|
+
export class Scheduler {
|
|
786
|
+
private readonly name: string
|
|
787
|
+
private readonly interval: number
|
|
788
|
+
private readonly callback: (now: number) => Promise<void>
|
|
789
|
+
private readonly verbose: boolean
|
|
790
|
+
|
|
791
|
+
private running = false
|
|
792
|
+
private last = 0
|
|
793
|
+
private errorSum = 0
|
|
794
|
+
private statsTimeoutId?: NodeJS.Timeout
|
|
795
|
+
|
|
796
|
+
/**
|
|
797
|
+
* Scheduler.
|
|
798
|
+
* @param name Logging name.
|
|
799
|
+
* @param interval Update interval in seconds.
|
|
800
|
+
* @param callback Callback function.
|
|
801
|
+
* @param verbose Verbose logging.
|
|
802
|
+
*/
|
|
803
|
+
constructor(name: string, interval: number, callback: (now: number) => Promise<void>, verbose = false) {
|
|
804
|
+
this.name = name
|
|
805
|
+
this.interval = interval * 1000
|
|
806
|
+
this.callback = callback
|
|
807
|
+
this.verbose = verbose
|
|
808
|
+
log.debug(`[${this.name}-scheduler] constructor interval=${this.interval}ms`)
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
start(): void {
|
|
812
|
+
log.debug(`[${this.name}-scheduler] start`)
|
|
813
|
+
this.running = true
|
|
814
|
+
this.scheduleNext()
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
stop(): void {
|
|
818
|
+
log.debug(`[${this.name}-scheduler] stop`)
|
|
819
|
+
this.running = false
|
|
820
|
+
if (this.statsTimeoutId) {
|
|
821
|
+
clearTimeout(this.statsTimeoutId)
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
private scheduleNext(): void {
|
|
826
|
+
if (!this.running) {
|
|
827
|
+
return
|
|
828
|
+
}
|
|
829
|
+
const now = Date.now()
|
|
830
|
+
if (this.last) {
|
|
831
|
+
this.errorSum += clampMinMax(now - this.last - this.interval, -this.interval, this.interval)
|
|
832
|
+
if (this.verbose) {
|
|
833
|
+
log.debug(`[${this.name}-scheduler] last=${now - this.last}ms drift=${this.errorSum}ms`)
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
this.last = now
|
|
837
|
+
this.statsTimeoutId = setTimeout(
|
|
838
|
+
async () => {
|
|
839
|
+
try {
|
|
840
|
+
const now = Date.now()
|
|
841
|
+
await this.callback(now)
|
|
842
|
+
const elapsed = Date.now() - now
|
|
843
|
+
if (elapsed > this.interval) {
|
|
844
|
+
log.warn(`[${this.name}-scheduler] callback elapsed=${elapsed}ms > ${this.interval}ms`)
|
|
845
|
+
} else if (this.verbose) {
|
|
846
|
+
log.debug(`[${this.name}-scheduler] callback elapsed=${elapsed}ms`)
|
|
847
|
+
}
|
|
848
|
+
} catch (err) {
|
|
849
|
+
log.error(`[${this.name}-scheduler] callback error: ${(err as Error).stack}`, err)
|
|
850
|
+
} finally {
|
|
851
|
+
this.scheduleNext()
|
|
852
|
+
}
|
|
853
|
+
},
|
|
854
|
+
this.interval - this.errorSum / 2,
|
|
855
|
+
)
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
//
|
|
860
|
+
export class PeerConnectionExternal {
|
|
861
|
+
public id: number
|
|
862
|
+
private process
|
|
863
|
+
private static cache = new Map<number, PeerConnectionExternal>()
|
|
864
|
+
|
|
865
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
866
|
+
constructor(options?: any) {
|
|
867
|
+
this.process = spawn('sleep', ['600'])
|
|
868
|
+
this.id = this.process.pid || -1
|
|
869
|
+
log.debug(`PeerConnectionExternal contructor: ${this.id}`, options)
|
|
870
|
+
PeerConnectionExternal.cache.set(this.id, this)
|
|
871
|
+
|
|
872
|
+
this.process.stdout.on('data', data => {
|
|
873
|
+
log.debug(`PeerConnectionExternal stdout: ${data}`)
|
|
874
|
+
})
|
|
875
|
+
|
|
876
|
+
this.process.stderr.on('data', data => {
|
|
877
|
+
log.debug(`PeerConnectionExternal stderr: ${data}`)
|
|
878
|
+
})
|
|
879
|
+
|
|
880
|
+
this.process.on('close', code => {
|
|
881
|
+
log.debug(`PeerConnectionExternal process exited with code ${code}`)
|
|
882
|
+
PeerConnectionExternal.cache.delete(this.id)
|
|
883
|
+
})
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
static get(id: number): PeerConnectionExternal | undefined {
|
|
887
|
+
return PeerConnectionExternal.cache.get(id)
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
891
|
+
async createOffer(options: any) {
|
|
892
|
+
log.debug(`PeerConnectionExternal createOffer`, { options })
|
|
893
|
+
return {}
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
setLocalDescription(description: string) {
|
|
897
|
+
log.debug(`PeerConnectionExternal setLocalDescription`, description)
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
setRemoteDescription(description: string) {
|
|
901
|
+
log.debug(`PeerConnectionExternal setRemoteDescription`, description)
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
export type PeerConnectionExternalMethod = 'createOffer' | 'setLocalDescription' | 'setRemoteDescription'
|
|
906
|
+
|
|
907
|
+
export function toTitleCase(s: string): string {
|
|
908
|
+
return s.charAt(0).toUpperCase() + s.slice(1)
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
export async function getFiles(dir: string, ext: string): Promise<string[]> {
|
|
912
|
+
const dirs = await fs.promises.readdir(dir, { withFileTypes: true })
|
|
913
|
+
const files = await Promise.all(
|
|
914
|
+
dirs.map(entry => {
|
|
915
|
+
const res = path.resolve(dir, entry.name)
|
|
916
|
+
return entry.isDirectory() ? getFiles(res, ext) : res
|
|
917
|
+
}),
|
|
918
|
+
)
|
|
919
|
+
return Array.prototype.concat(...files).filter(f => f.endsWith(ext))
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
/**
|
|
923
|
+
* Format number to the specified precision.
|
|
924
|
+
* @param value value to format
|
|
925
|
+
* @param precision precision
|
|
926
|
+
*/
|
|
927
|
+
export function toPrecision(value: number, precision = 3): string {
|
|
928
|
+
return (Math.round(value * 10 ** precision) / 10 ** precision).toFixed(precision)
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
export async function getDefaultNetworkInterface(): Promise<string> {
|
|
932
|
+
const { stdout } = await runShellCommand(`ip route | awk '/default/ {print $5; exit}' | tr -d ''`)
|
|
933
|
+
return stdout.trim()
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
export async function checkNetworkInterface(device: string): Promise<void> {
|
|
937
|
+
await runShellCommand(`ip route | grep -q "dev ${device}"`)
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
export async function portForwarder(port: number, listenInterface?: string) {
|
|
941
|
+
if (!listenInterface) {
|
|
942
|
+
listenInterface = await getDefaultNetworkInterface()
|
|
943
|
+
}
|
|
944
|
+
const controller = new AbortController()
|
|
945
|
+
Object.entries(networkInterfaces()).forEach(([iface, nets]) => {
|
|
946
|
+
if (listenInterface !== '0.0.0.0' && iface !== listenInterface) return
|
|
947
|
+
if (!nets) return
|
|
948
|
+
for (const n of nets) {
|
|
949
|
+
if (n.internal || n.address === '127.0.0.1' || n.family !== 'IPv4') {
|
|
950
|
+
continue
|
|
951
|
+
}
|
|
952
|
+
const msg = `portForwarder on ${iface} (${n.address}:${port})`
|
|
953
|
+
const server = net
|
|
954
|
+
.createServer(from => {
|
|
955
|
+
const to = net.createConnection({ host: '127.0.0.1', port })
|
|
956
|
+
from.once('error', err => {
|
|
957
|
+
log.error(`${msg} error: ${err.stack}`)
|
|
958
|
+
to.destroy()
|
|
959
|
+
})
|
|
960
|
+
to.once('error', err => {
|
|
961
|
+
log.error(`${msg} error: ${err.stack}`)
|
|
962
|
+
from.destroy()
|
|
963
|
+
})
|
|
964
|
+
from.pipe(to)
|
|
965
|
+
to.pipe(from)
|
|
966
|
+
})
|
|
967
|
+
.listen({ port, host: n.address, signal: controller.signal })
|
|
968
|
+
server.on('listening', () => {
|
|
969
|
+
log.debug(`${msg} listening`)
|
|
970
|
+
})
|
|
971
|
+
server.once('error', err => {
|
|
972
|
+
log.error(`${msg} error: ${err.stack}`)
|
|
973
|
+
})
|
|
974
|
+
}
|
|
975
|
+
})
|
|
976
|
+
|
|
977
|
+
return () => {
|
|
978
|
+
log.debug(`portForwarder on port ${port} stop`)
|
|
979
|
+
controller.abort()
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
export async function pageScreenshot(
|
|
984
|
+
url: string,
|
|
985
|
+
filePath: string,
|
|
986
|
+
width = 1920,
|
|
987
|
+
height = 1024,
|
|
988
|
+
selector = 'body',
|
|
989
|
+
headers?: Record<string, string>,
|
|
990
|
+
extraCss?: string,
|
|
991
|
+
): Promise<void> {
|
|
992
|
+
log.debug(`pageScreenshot ${url} -> ${filePath}`)
|
|
993
|
+
await fs.promises.mkdir(path.dirname(filePath), { recursive: true })
|
|
994
|
+
let executablePath = process.env.CHROMIUM_PATH
|
|
995
|
+
if (!executablePath || !fs.existsSync(executablePath)) {
|
|
996
|
+
executablePath = await checkChromeExecutable()
|
|
997
|
+
}
|
|
998
|
+
const browser = await puppeteer.launch({
|
|
999
|
+
headless: true,
|
|
1000
|
+
executablePath,
|
|
1001
|
+
defaultViewport: {
|
|
1002
|
+
width,
|
|
1003
|
+
height,
|
|
1004
|
+
deviceScaleFactor: 1,
|
|
1005
|
+
isMobile: false,
|
|
1006
|
+
hasTouch: false,
|
|
1007
|
+
isLandscape: false,
|
|
1008
|
+
},
|
|
1009
|
+
args: [
|
|
1010
|
+
'--no-sandbox',
|
|
1011
|
+
'--disable-setuid-sandbox',
|
|
1012
|
+
//'--remote-debugging-port=9222',
|
|
1013
|
+
],
|
|
1014
|
+
})
|
|
1015
|
+
const page = await browser.newPage()
|
|
1016
|
+
if (headers) {
|
|
1017
|
+
await page.setExtraHTTPHeaders(headers)
|
|
1018
|
+
}
|
|
1019
|
+
if (extraCss) {
|
|
1020
|
+
await page.evaluateOnNewDocument((css: string) => {
|
|
1021
|
+
document.addEventListener('DOMContentLoaded', () => {
|
|
1022
|
+
const style = document.createElement('style')
|
|
1023
|
+
style.setAttribute('id', 'webrtcperf-extra-style')
|
|
1024
|
+
style.setAttribute('type', 'text/css')
|
|
1025
|
+
style.innerHTML = css
|
|
1026
|
+
document.head.appendChild(style)
|
|
1027
|
+
})
|
|
1028
|
+
}, extraCss)
|
|
1029
|
+
}
|
|
1030
|
+
await page.goto(url, {
|
|
1031
|
+
waitUntil: ['domcontentloaded', 'networkidle0'],
|
|
1032
|
+
timeout: 60 * 1000,
|
|
1033
|
+
})
|
|
1034
|
+
try {
|
|
1035
|
+
const element = await page.waitForSelector(selector, {
|
|
1036
|
+
visible: true,
|
|
1037
|
+
timeout: 15 * 1000,
|
|
1038
|
+
})
|
|
1039
|
+
if (!element) {
|
|
1040
|
+
throw new Error(`pageScreenshot selector "${selector}" not found`)
|
|
1041
|
+
}
|
|
1042
|
+
await element.screenshot({ path: filePath })
|
|
1043
|
+
} catch (err) {
|
|
1044
|
+
log.error(`pageScreenshot error: ${(err as Error).message}`)
|
|
1045
|
+
} finally {
|
|
1046
|
+
await page.close()
|
|
1047
|
+
await browser.close()
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
export function enabledForSession(index: number, value: boolean | string | number): boolean {
|
|
1052
|
+
if (value === true || value === 'true') {
|
|
1053
|
+
return true
|
|
1054
|
+
} else if (value === false || value === 'false' || value === undefined) {
|
|
1055
|
+
return false
|
|
1056
|
+
} else if (typeof value === 'string') {
|
|
1057
|
+
if (value.includes('-')) {
|
|
1058
|
+
const [start, end] = value.split('-').map(s => parseInt(s))
|
|
1059
|
+
if (isFinite(start) && index < start) {
|
|
1060
|
+
return false
|
|
1061
|
+
}
|
|
1062
|
+
if (isFinite(end) && index > end) {
|
|
1063
|
+
return false
|
|
1064
|
+
}
|
|
1065
|
+
return true
|
|
1066
|
+
} else {
|
|
1067
|
+
const indexes = value
|
|
1068
|
+
.split(',')
|
|
1069
|
+
.map(s => s.trim())
|
|
1070
|
+
.filter(s => s.length)
|
|
1071
|
+
.map(s => parseInt(s))
|
|
1072
|
+
return indexes.includes(index)
|
|
1073
|
+
}
|
|
1074
|
+
} else if (index === value) {
|
|
1075
|
+
return true
|
|
1076
|
+
}
|
|
1077
|
+
return false
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
export function increaseKey(o: Record<string, number>, key: string, value?: number): void {
|
|
1081
|
+
if (value === undefined || !isFinite(value)) return
|
|
1082
|
+
if (o[key] === undefined) {
|
|
1083
|
+
o[key] = 0
|
|
1084
|
+
}
|
|
1085
|
+
o[key] += value
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
export async function chunkedPromiseAll<T, R>(
|
|
1089
|
+
items: T[],
|
|
1090
|
+
f: (v: T, index: number) => Promise<R>,
|
|
1091
|
+
chunkSize = 1,
|
|
1092
|
+
): Promise<R[]> {
|
|
1093
|
+
const results = Array<R>(items.length)
|
|
1094
|
+
for (let index = 0; index < items.length; index += chunkSize) {
|
|
1095
|
+
await Promise.allSettled(
|
|
1096
|
+
items.slice(index, index + chunkSize).map(async (item, i) => {
|
|
1097
|
+
const res = await f(item, index + i)
|
|
1098
|
+
if (res !== undefined) results[index + i] = res
|
|
1099
|
+
}),
|
|
1100
|
+
)
|
|
1101
|
+
}
|
|
1102
|
+
return results
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
export function maybeNumber(s: string): string | number {
|
|
1106
|
+
const n = parseFloat(s)
|
|
1107
|
+
return !isNaN(n) ? n : s
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
export enum FFProbeProcess {
|
|
1111
|
+
Skip = 'skip',
|
|
1112
|
+
Stop = 'stop',
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
/**
|
|
1116
|
+
* It runs the ffprobe command to extract the video/video frames.
|
|
1117
|
+
* @param fpath The file path.
|
|
1118
|
+
* @param kind The kind of the media (video or audio).
|
|
1119
|
+
* @param entries Which entries to show.
|
|
1120
|
+
* @param filters Apply filters.
|
|
1121
|
+
* @param frameProcess A function to process the frame. The function return value could be:
|
|
1122
|
+
* - the modified frame object
|
|
1123
|
+
* - `FFProbeProcess.Skip` to skip the frame from the output
|
|
1124
|
+
* - `FFProbeProcess.Stop` to stop processing and immediately return the collected frames.
|
|
1125
|
+
*/
|
|
1126
|
+
export async function ffprobe(
|
|
1127
|
+
fpath: string,
|
|
1128
|
+
kind = 'video',
|
|
1129
|
+
entries = '',
|
|
1130
|
+
filters = '',
|
|
1131
|
+
frameProcess?: (_frame: Record<string, string>) => Record<string, string> | FFProbeProcess,
|
|
1132
|
+
): Promise<Record<string, string>[]> {
|
|
1133
|
+
const cmd = `\
|
|
1134
|
+
exec ffprobe -loglevel error ${kind === 'video' ? '-select_streams v' : ''} -show_frames -print_format compact \
|
|
1135
|
+
${entries ? `-show_entries ${entries}` : ''} \
|
|
1136
|
+
-f lavfi -i '${kind === 'video' ? '' : 'a'}movie=${fpath}${filters ? `,${filters}` : ''}'`
|
|
1137
|
+
const frames = [] as Record<string, string>[]
|
|
1138
|
+
let stderr = ''
|
|
1139
|
+
let stopProcessing = false
|
|
1140
|
+
return new Promise((resolve, reject) => {
|
|
1141
|
+
const p = spawn(cmd, {
|
|
1142
|
+
shell: true,
|
|
1143
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1144
|
+
})
|
|
1145
|
+
p.stdout.on('data', data => {
|
|
1146
|
+
if (stopProcessing) return
|
|
1147
|
+
const frame = data
|
|
1148
|
+
.toString()
|
|
1149
|
+
.split('|')
|
|
1150
|
+
.reduce(
|
|
1151
|
+
(prev: Record<string, string>, cur: string) => {
|
|
1152
|
+
const [key, value] = cur.split('=')
|
|
1153
|
+
if (value && !key.startsWith('side_datum')) {
|
|
1154
|
+
prev[key.replace(/[:.]/g, '_')] = value
|
|
1155
|
+
}
|
|
1156
|
+
return prev
|
|
1157
|
+
},
|
|
1158
|
+
{} as Record<string, string>,
|
|
1159
|
+
)
|
|
1160
|
+
if (frameProcess) {
|
|
1161
|
+
const newFrame = frameProcess(frame)
|
|
1162
|
+
if (newFrame === FFProbeProcess.Skip) {
|
|
1163
|
+
// Skip the frame.
|
|
1164
|
+
} else if (newFrame === FFProbeProcess.Stop) {
|
|
1165
|
+
stopProcessing = true
|
|
1166
|
+
p.kill('SIGINT')
|
|
1167
|
+
} else {
|
|
1168
|
+
frames.push(newFrame)
|
|
1169
|
+
}
|
|
1170
|
+
} else {
|
|
1171
|
+
frames.push(frame)
|
|
1172
|
+
}
|
|
1173
|
+
})
|
|
1174
|
+
p.stderr.on('data', data => {
|
|
1175
|
+
stderr += data
|
|
1176
|
+
})
|
|
1177
|
+
p.once('error', err => reject(err))
|
|
1178
|
+
p.once('close', code => {
|
|
1179
|
+
if (code !== 0 && !stopProcessing) {
|
|
1180
|
+
reject(new Error(`${cmd} failed with code ${code}: ${stderr}`))
|
|
1181
|
+
} else {
|
|
1182
|
+
resolve(frames)
|
|
1183
|
+
}
|
|
1184
|
+
})
|
|
1185
|
+
})
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
export function buildIvfHeader(width = 1920, height = 1080, frameRate = 30, fourcc = 'MJPG') {
|
|
1189
|
+
const buffer = Buffer.alloc(32)
|
|
1190
|
+
buffer.write('DKIF', 0, 'utf8')
|
|
1191
|
+
buffer.writeUint16LE(0, 4) // version
|
|
1192
|
+
buffer.writeUint16LE(32, 6) // header size
|
|
1193
|
+
buffer.write(fourcc, 8, 'utf8')
|
|
1194
|
+
buffer.writeUint16LE(width, 12)
|
|
1195
|
+
buffer.writeUint16LE(height, 14)
|
|
1196
|
+
buffer.writeUint32LE(frameRate, 16)
|
|
1197
|
+
buffer.writeUint32LE(1, 20)
|
|
1198
|
+
buffer.writeUint32LE(0, 24) // frame count
|
|
1199
|
+
buffer.writeUint32LE(0, 28) // unused
|
|
1200
|
+
return buffer
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
export async function ffmpeg(command = 'video', processFn: (_frame: Buffer) => void): Promise<void> {
|
|
1204
|
+
const port = 10000 + Math.floor(Math.random() * 10000)
|
|
1205
|
+
const cmd = `exec ffmpeg -hide_banner -loglevel warning ${command} zmq:tcp://127.0.0.1:${port}`
|
|
1206
|
+
log.debug(`${cmd}`)
|
|
1207
|
+
let stderr = ''
|
|
1208
|
+
|
|
1209
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
1210
|
+
const zmq = require('zeromq')
|
|
1211
|
+
const sub = new zmq.Subscriber()
|
|
1212
|
+
const p = spawn(cmd, {
|
|
1213
|
+
shell: true,
|
|
1214
|
+
stdio: ['ignore', 'ignore', 'pipe'],
|
|
1215
|
+
})
|
|
1216
|
+
p.stderr.on('data', data => {
|
|
1217
|
+
stderr += data
|
|
1218
|
+
})
|
|
1219
|
+
p.once('error', err => {
|
|
1220
|
+
sub.close()
|
|
1221
|
+
throw err
|
|
1222
|
+
})
|
|
1223
|
+
p.once('close', code => {
|
|
1224
|
+
sub.close()
|
|
1225
|
+
if (code !== 0) {
|
|
1226
|
+
throw new Error(`${cmd} failed with code ${code}: ${stderr}`)
|
|
1227
|
+
}
|
|
1228
|
+
})
|
|
1229
|
+
sub.connect(`tcp://127.0.0.1:${port}`)
|
|
1230
|
+
sub.subscribe('')
|
|
1231
|
+
for await (const [msg] of sub) {
|
|
1232
|
+
await processFn(msg)
|
|
1233
|
+
}
|
|
1234
|
+
sub.close()
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
export async function analyzeColors(fpath: string) {
|
|
1238
|
+
let Y = 0
|
|
1239
|
+
let U = 0
|
|
1240
|
+
let V = 0
|
|
1241
|
+
let SAT = 0
|
|
1242
|
+
let HUE = 0
|
|
1243
|
+
let count = 0
|
|
1244
|
+
await ffprobe(
|
|
1245
|
+
fpath,
|
|
1246
|
+
'video',
|
|
1247
|
+
'frame=lavfi.signalstats.YAVG,lavfi.signalstats.UAVG,lavfi.signalstats.VAVG,lavfi.signalstats.SATAVG,lavfi.signalstats.HUEAVG',
|
|
1248
|
+
'signalstats',
|
|
1249
|
+
frame => {
|
|
1250
|
+
Y += parseFloat(frame.tag_lavfi_signalstats_YAVG)
|
|
1251
|
+
U += parseFloat(frame.tag_lavfi_signalstats_UAVG)
|
|
1252
|
+
V += parseFloat(frame.tag_lavfi_signalstats_VAVG)
|
|
1253
|
+
SAT += parseFloat(frame.tag_lavfi_signalstats_SATAVG)
|
|
1254
|
+
HUE += parseFloat(frame.tag_lavfi_signalstats_HUEAVG)
|
|
1255
|
+
count++
|
|
1256
|
+
return FFProbeProcess.Skip
|
|
1257
|
+
},
|
|
1258
|
+
)
|
|
1259
|
+
return { YAvg: Y / count, UAvg: U / count, VAvg: V / count, SatAvg: SAT / count, HueAvg: HUE / count }
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
/**
|
|
1263
|
+
* Wait for the process to stop or kill it after the timeout.
|
|
1264
|
+
* @param pid The process pid
|
|
1265
|
+
* @param timeout The maximum wait time in milliseconds
|
|
1266
|
+
* @returns `true` if the process stopped, `false` if the process was killed.
|
|
1267
|
+
*/
|
|
1268
|
+
export async function waitStopProcess(pid: number, timeout = 5000): Promise<boolean> {
|
|
1269
|
+
log.debug(`waitStopProcess pid: ${pid} timeout: ${timeout}`)
|
|
1270
|
+
const now = Date.now()
|
|
1271
|
+
while (Date.now() - now < timeout) {
|
|
1272
|
+
try {
|
|
1273
|
+
process.kill(pid, 0)
|
|
1274
|
+
await sleep(Math.max(timeout / 10, 200))
|
|
1275
|
+
} catch {
|
|
1276
|
+
return true
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
log.warn(`waitStopProcess pid: ${pid} timeout`)
|
|
1280
|
+
try {
|
|
1281
|
+
process.kill(pid, 'SIGKILL')
|
|
1282
|
+
} catch {
|
|
1283
|
+
return true
|
|
1284
|
+
}
|
|
1285
|
+
return false
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
export async function getDockerLogsPath(): Promise<string> {
|
|
1289
|
+
const containerId = await fs.promises.readFile(`${os.homedir()}/.webrtcperf/docker.id`, 'utf-8')
|
|
1290
|
+
const logPath = `/var/lib/docker/containers/${containerId}/${containerId}-json.log`
|
|
1291
|
+
if (!fs.existsSync(logPath)) {
|
|
1292
|
+
throw new Error(`docker logs path ${logPath} not found`)
|
|
1293
|
+
}
|
|
1294
|
+
return logPath
|
|
1295
|
+
}
|