Skip to content

Commit

Permalink
Redesign/processes (#81)
Browse files Browse the repository at this point in the history
* ♻️ Processes >> Refactoring: Completely rebuilt. Checks running processes with 'ps'. In addition, windows is now supported. 🤷‍♀️
  • Loading branch information
borsTiHD authored Aug 7, 2021
1 parent ca8e1a5 commit 85a2e2c
Show file tree
Hide file tree
Showing 6 changed files with 416 additions and 198 deletions.
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- App: Changing page title to represent the current module.
- App: Changing hidden dev page position (only for dev).

### Refactor
- Processes: Completely rebuilt. Checks running processes with 'ps'. In addition, windows is now supported. 🤷‍♀️

## [0.2.0] - 2021-08-05
### Added
- Terminal: New page to start terminals on the host system.
Expand Down
182 changes: 182 additions & 0 deletions src/api/socket.io/controllers/getProcesses.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
import childProcess from 'child_process'
import path from 'path'
import util from 'util'

// Promisefied execFile
const execFile = util.promisify(childProcess.execFile)

// CONSTs
const isWin = process.platform === 'win32'
const TEN_MEGABYTES = 1000 * 1000 * 10
const ERROR_MESSAGE_PARSING_FAILED = 'Error on parsing script output'

// Collecting processes with 'ps' - copied and modified from https://github.com/sindresorhus/ps-list - thank you :)
async function nonWindows(options) {
try {
return await nonWindowsSingleCall(options)
} catch (err) { // If the error is not a parsing error, it should manifest itself in multicall version too.
console.error('[Socket.io] -> Error on executing nonWindowsSingleCall(), trying multiple calls next:', err)
return await nonWindowsMultipleCalls(options)
}
}

// Collecting processes with one 'ps' call
async function nonWindowsSingleCall(options) {
const command = 'ps'
const flags = options.all === false ? 'wwxo' : 'awwxo'
const psFields = 'pid,ppid,uid,user,%cpu,%mem,time,comm,args' // original: 'pid,ppid,uid,%cpu,%mem,comm,args'

// TODO: Use the promise version of `execFile` when https://github.com/nodejs/node/issues/28244 is fixed.
const [psPid, stdout] = await new Promise((resolve, reject) => {
const child = childProcess.execFile(command, [flags, psFields], { maxBuffer: TEN_MEGABYTES }, (error, stdout) => {
if (error === null) {
resolve([child.pid, stdout])
} else {
reject(error)
}
})
})

// Parsing into Lines
const lines = stdout.trim().split('\n')
lines.shift() // deletes first line with headers

// For parsing comm + args
let psIndex
let commPosition
let argsPosition

// TODO: Use named capture groups when targeting Node.js 10
const psOutputRegex = /^[ \t]*(?<pid>\d+)[ \t]+(?<ppid>\d+)[ \t]+(?<uid>\d+)[ \t]+(?<user>\D*?)[ \t]+(?<cpu>\d+\.\d+|\d+)[ \t]+(?<memory>\d+\.\d+|\d+)[ \t]+(?<time>.*?)[ \t]+/
// const psOutputRegex = /^[ \t]*(?<pid>\d+)[ \t]+(?<ppid>\d+)[ \t]+(?<uid>\d+)[ \t]+(?<cpu>\d+\.\d+)[ \t]+(?<memory>\d+\.\d+)[ \t]+/

// Parsing single lines
const processes = lines.map((line, index) => {
const match = psOutputRegex.exec(line)
if (match === null) {
throw new Error(ERROR_MESSAGE_PARSING_FAILED)
}

const { pid, ppid, uid, user, cpu, memory, time } = match.groups

const processInfo = {
pid: Number.parseInt(pid, 10),
ppid: Number.parseInt(ppid, 10),
uid: Number.parseInt(uid, 10),
user,
cpu: Number.parseFloat(cpu) || Number.parseInt(cpu, 10),
memory: Number.parseFloat(memory) || Number.parseInt(memory, 10),
time,
name: undefined,
cmd: undefined
}

if (processInfo.pid === psPid) {
psIndex = index
commPosition = line.indexOf(command, match[0].length)
argsPosition = line.indexOf(command, commPosition + 2)
}

return processInfo
})

if (psIndex === undefined || commPosition === -1 || argsPosition === -1) {
throw new Error(ERROR_MESSAGE_PARSING_FAILED)
}

const commLength = argsPosition - commPosition
for (const [index, line] of lines.entries()) {
processes[index].name = line.slice(commPosition, commPosition + commLength).trim()
processes[index].cmd = line.slice(argsPosition).trim()
}

processes.splice(psIndex, 1)
return processes
}

// Collecting processes with multiple 'ps' calls
async function nonWindowsMultipleCalls(options) {
const command = 'ps'
const flags = (options.all === false ? '' : 'a') + 'wwxo'
const psFields = ['comm', 'args', 'ppid', 'uid', 'user', '%cpu', '%mem', 'time'] // default: ['comm', 'args', 'ppid', 'uid', '%cpu', '%mem']
const ret = {}

await Promise.all(psFields.map(async(cmd) => {
const { stdout } = await execFile(command, [flags, `pid,${cmd}`], { maxBuffer: TEN_MEGABYTES })

for (let line of stdout.trim().split('\n').slice(1)) {
line = line.trim()
const [pid] = line.split(' ', 1)
const val = line.slice(pid.length + 1).trim()

if (ret[pid] === undefined) {
ret[pid] = {}
}

ret[pid][cmd] = val
}
}))

// Filter out inconsistencies as there might be race
// issues due to differences in `ps` between the spawns
return Object.entries(ret)
.filter(([, value]) => value.comm && value.args && value.ppid && value.uid && value.user && value['%cpu'] && value['%mem'] && value.time)
.map(([key, value]) => ({
pid: Number.parseInt(key, 10),
ppid: Number.parseInt(value.ppid, 10),
uid: Number.parseInt(value.uid, 10),
user: value.user,
cpu: Number.parseFloat(value['%cpu']) || Number.parseInt(value['%cpu'], 10),
memory: Number.parseFloat(value['%mem']) || Number.parseInt(value['%mem'], 10),
time: value.time,
name: path.basename(value.comm),
cmd: value.args
}))
}

// Collecting processes on windows
async function isWindows() {
const command = 'powershell'
const args = ['Get-Process'] // 'Get-Process | Format-Table Id, Cpu, VM, TotalProcessorTime, Name, Path'
const { stdout } = await execFile(command, args, { maxBuffer: TEN_MEGABYTES })

// Parsing into Lines
const lines = stdout.trim().split('\n').slice(2) // deletes first two lines with headers
lines[lines.length - 1] += '\n' // appends a 'new line' to the last item in array -> so that the regex rule can take effect

// 'Get-Process' regex
const psOutputRegex = /^[ \t]*(?<handles>\d+)[ \t]*(?<npm>\d+)[ \t]*(?<pm>\d+)[ \t]*(?<ws>\d+)[ \t]+(?<cpu>\d+\.\d+,\d+|\d+,\d+|\d+)[ \t]+(?<pid>\d+)[ \t]+(?<si>\d+)[ \t]+(?<name>.*?)[ \n]/

// Parsing single lines
const processes = lines.map((line) => {
const match = psOutputRegex.exec(line)
if (match === null) {
throw new Error(ERROR_MESSAGE_PARSING_FAILED)
}

// Building result
const { handles, npm, pm, ws, cpu, pid, si, name } = match.groups
const processInfo = {
handles: Number.parseInt(handles, 10),
npm: Number.parseInt(npm, 10),
pm: Number.parseInt(pm, 10),
ws: Number.parseInt(ws, 10),
cpu: Number.parseFloat(cpu) || Number.parseInt(cpu, 10),
pid: Number.parseInt(pid, 10),
si: Number.parseInt(si, 10),
name
}
return processInfo
})
return processes
}

// Export module
export default async(options = {}) => {
// Determines collecting data depending on operating system
if (isWin) {
return isWindows(options)
} else {
return await nonWindows(options)
}
}
125 changes: 21 additions & 104 deletions src/api/socket.io/rooms/processes.js
Original file line number Diff line number Diff line change
@@ -1,123 +1,40 @@
// Imports
import { spawn } from 'child_process'
import initListener from '../controllers/roomEventListener.js'
import getProcesses from '../controllers/getProcesses.js'

// Room Event name
const eventName = 'processes'

export default (io, roomName) => {
// Childprocess
let child = null
// Interval
let intervalId = null

// Room event listener with callbacks for starting/stopping tasks
initListener(io, roomName, () => {
// Create Room Event: Initialize room tasks
child = initialize()
const duration = 2 * 1000 // Interval duration in milliseconds
intervalId = initialize(duration)
}, () => {
// Delete Room Event: Killing child
child.kill()
child = null
// Delete Room Event: Clearing interval
clearInterval(intervalId)
intervalId = null
})

// Parsing raw output
async function parseProcessData(raw) {
const outputArr = raw.split('\n')
if (Array.isArray(outputArr) && outputArr.length > 6) {
const info = outputArr.slice(0, 5)
const columns = outputArr.slice(6, 7)[0].trim().split(/\s+/) // Get only columns, trims leading and trailing whitespaces, also splits at every +whitespace
const processes = outputArr.slice(7).map((rawItem) => {
const item = rawItem.trim().split(/\s+/) // Trim leading and trailing whitespaces, also splits at every +whitespace
// Exeption if an item is longer than 12 values
if (Array.isArray(item) && item.length > 12) {
// Problem: If our process item is longer than 12 columns, the last column is a command with separation
// The solution is to cut out all items with index larger than 11 and turn them into one item
// e.g.:
// - oldItem: ["72", "root", "0", "-20", "0", "0", "0", "I", "0,0", "0,0", "0:00.00", "DWC", "Notif+"]
// - newItem: ["72", "root", "0", "-20", "0", "0", "0", "I", "0,0", "0,0", "0:00.00", "DWC Notif+"]
// Combines every additional index after 11
const combined = item.slice(11).join(' ')

// Creating a new array with combined item on index 11
const newItem = []
item.some((value, index) => {
if (index <= 10) {
// Adding every value with index smaller or equal 10 (includes up to index 10)
newItem.push(value)
} else if (index === 11) {
// Index 11 is completely replaced by the 'combined value'
newItem.push(combined)
}
// Stopping loop if index equal or greater 12 - no need to looping more, its already added with the 'combined value'
return index >= 12
})
return newItem
}
return item
})
return { info, columns, processes }
} else {
console.error('[Socket.io] -> Error on parsing script output:', raw)
throw new Error('Error on parsing script output.')
}
}

// Room logic
function initialize() {
function initialize(duration) {
console.log(`[Socket.io] -> Room '${roomName}' starts performing its tasks`)
const eventName = 'processes'
try {
// Collecting chunk data
let chunkData = null

// Spawn command
const command = 'top'
const args = ['-b']
const child = spawn(command, args)

// Data output
child.stdout.setEncoding('utf8')
child.stdout.on('data', (data) => {
const convertedData = data.toString()
if (!chunkData) {
// If no chunkdata exists, we will save stream output
chunkData = convertedData
} else if (/top - /.test(convertedData)) {
// New data contains a new output/interval
// Old chunkdata was completed
// Parsing old chunkdata and send result to socket room
parseProcessData(chunkData).then((result) => {
io.to(roomName).emit(eventName, { _status: 'ok', data: result })
}).catch((err) => {
io.to(roomName).emit(eventName, { _status: 'error', error: err.message, info: 'Error on parsing output' })
}).finally(() => {
// Old saved data send to socket
// New output will be saved as a new interval of data
// We clean old chunks and overwrite it with new output
// New output might contain line breaks, these will be removed
chunkData = null
chunkData = convertedData.trim()
})
} else {
// Adds output to chunkdata
chunkData += convertedData
const id = setInterval(async() => {
try {
const isWin = process.platform === 'win32'
const psList = await getProcesses()
io.to(roomName).emit(eventName, { _status: 'ok', data: { processes: psList, isWin } })
} catch (error) {
io.to(roomName).emit(eventName, { _status: 'error', error: error.message, info: 'Error on starting tasks' })
}
})

// Error output
child.stderr.setEncoding('utf8')
child.stderr.on('data', (data) => {
const convertedData = data.toString()
io.to(roomName).emit(eventName, { _status: 'error', error: convertedData, info: 'Error output from child process' })
})

// Child closed with error
child.on('error', (error) => {
io.to(roomName).emit(eventName, { _status: 'error', error: error.message, info: 'Child process closed with error' })
})

// Child closed
child.on('close', (code) => {
io.to(roomName).emit(eventName, { _status: 'closed', exitcode: code })
})
}, duration)

return child
return id
} catch (error) {
io.to(roomName).emit(eventName, { _status: 'error', error: error.message, info: 'Something went wrong' })
}
Expand Down
Loading

0 comments on commit 85a2e2c

Please sign in to comment.