diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 7ea947a..f98df47 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -14,7 +14,11 @@ jobs: - name: Setup repo uses: actions/checkout@v2 - name: Setup node - uses: actions/setup-node@v1 + uses: actions/setup-node@v4 + with: + # Pinned: the legacy xo@0.20 toolchain crashes on modern Node + # (util.isDate was removed). Revisit when the toolchain is updated. + node-version: 14 - name: Install dev dependencies run: | npm install --only=dev diff --git a/.github/workflows/test-macos.yml b/.github/workflows/test-macos.yml index 37d58e6..0a8bf5b 100644 --- a/.github/workflows/test-macos.yml +++ b/.github/workflows/test-macos.yml @@ -13,7 +13,7 @@ jobs: strategy: fail-fast: false matrix: - node: [current, 16, 14, 12, 10, 8, 6, 4] + node: [current, 22, 20, 18] steps: - name: Setup repo uses: actions/checkout@v3 diff --git a/.github/workflows/test-ubuntu.yml b/.github/workflows/test-ubuntu.yml index dd059e2..9f0cc2c 100644 --- a/.github/workflows/test-ubuntu.yml +++ b/.github/workflows/test-ubuntu.yml @@ -13,7 +13,7 @@ jobs: strategy: fail-fast: false matrix: - node: [current, 16, 14, 12, 10, 8, 6, 4] + node: [current, 22, 20, 18] steps: - name: Setup repo uses: actions/checkout@v3 diff --git a/.github/workflows/test-windows.yml b/.github/workflows/test-windows.yml index 9e3414f..4fcb35c 100644 --- a/.github/workflows/test-windows.yml +++ b/.github/workflows/test-windows.yml @@ -8,12 +8,14 @@ on: jobs: test: + # windows-latest is Windows Server 2025, which ships without wmic, so the + # integration tests here run against the PowerShell fallback in lib/get.js. runs-on: windows-latest name: AVA & TSD & Benchmark & Codecov strategy: fail-fast: false matrix: - node: [current, 16, 14, 12, 10, 8, 6, 4] + node: [current, 22, 20, 18] steps: - name: Setup repo uses: actions/checkout@v3 diff --git a/lib/bin.js b/lib/bin.js index e82f868..a74954d 100644 --- a/lib/bin.js +++ b/lib/bin.js @@ -42,7 +42,9 @@ function run(cmd, args, options, done) { ch.on('error', function(err) { if (executed) return; executed = true; - done(new Error(err)); + // Pass the original error through so callers can inspect err.code + // (e.g. 'ENOENT' when the binary is missing) to decide on a fallback. + done(err); }); ch.on('close', function(code) { diff --git a/lib/get.js b/lib/get.js index 7f94efd..68abad1 100644 --- a/lib/get.js +++ b/lib/get.js @@ -15,6 +15,7 @@ var platformToMethod = { var methodToRequireFn = { ps: () => require('./ps'), wmic: () => require('./wmic'), + powershell: () => require('./powershell'), }; var platform = os.platform(); @@ -36,6 +37,24 @@ function get(callback) { ' is not supported yet, please open an issue (https://github.com/simonepri/pidtree)' ) ); + return; + } + + // On Windows wmic has been removed from recent versions (e.g. Windows 11 + // 24H2 and Windows Server 2025). Try it first since it is faster when + // present, and fall back to PowerShell when the binary cannot be found. + if (method === 'wmic') { + var wmic = methodToRequireFn.wmic(); + wmic(function(err, list) { + if (err && err.code === 'ENOENT') { + var powershell = methodToRequireFn.powershell(); + powershell(callback); + return; + } + + callback(err, list); + }); + return; } var list = methodToRequireFn[method](); diff --git a/lib/powershell.js b/lib/powershell.js new file mode 100644 index 0000000..2fabb69 --- /dev/null +++ b/lib/powershell.js @@ -0,0 +1,70 @@ +'use strict'; + +var os = require('os'); +var bin = require('./bin'); + +// PowerShell command used to list every process as " " lines. +// Get-CimInstance is the supported replacement for the removed wmic utility +// (see https://github.com/simonepri/pidtree/issues/20). +// $ProgressPreference is silenced so PowerShell does not serialize its +// progress stream (e.g. "Preparing modules for first use.") to stderr as +// CLIXML, which would otherwise be treated as an error. +var COMMAND = + "$ProgressPreference = 'SilentlyContinue'; " + + 'Get-CimInstance -ClassName Win32_Process | ' + + 'ForEach-Object { "$($_.ParentProcessId) $($_.ProcessId)" }'; + +// The command is passed through -EncodedCommand (Base64 of the UTF-16LE +// string) to avoid any cross-shell argument quoting issues on Windows. +var ENCODED = Buffer.from(COMMAND, 'utf16le').toString('base64'); + +/** + * Gets the list of all the pids of the system through PowerShell. + * Used as a fallback on Windows installations where wmic is not available + * (e.g. Windows 11 24H2 and Windows Server 2025). + * @param {Function} callback(err, list) + */ +function powershell(callback) { + var args = ['-NoProfile', '-NonInteractive', '-EncodedCommand', ENCODED]; + var options = {windowsHide: true}; + bin('powershell', args, options, function(err, stdout, code) { + if (err) { + callback(err); + return; + } + + if (code !== 0) { + callback( + new Error('pidtree powershell command exited with code ' + code) + ); + return; + } + + // Example of stdout + // + // 0 777 + // 777 778 + // 0 779 + + try { + stdout = stdout.split(os.EOL); + + var list = []; + for (var i = 0; i < stdout.length; i++) { + stdout[i] = stdout[i].trim(); + if (!stdout[i]) continue; + var parts = stdout[i].split(/\s+/); + var ppid = parseInt(parts[0], 10); // PPID + var pid = parseInt(parts[1], 10); // PID + if (isNaN(ppid) || isNaN(pid)) continue; + list.push([ppid, pid]); + } + + callback(null, list); + } catch (error) { + callback(error); + } + }); +} + +module.exports = powershell; diff --git a/package.json b/package.json index 6e1e672..f23f4a6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pidtree", - "version": "0.6.0", + "version": "0.6.1", "description": "Cross platform children list of a PID", "license": "MIT", "homepage": "http://github.com/simonepri/pidtree#readme", diff --git a/test/get.js b/test/get.js new file mode 100644 index 0000000..2e69fc6 --- /dev/null +++ b/test/get.js @@ -0,0 +1,83 @@ +import test from 'ava'; +import mockery from 'mockery'; + +import pify from 'pify'; + +test.before(() => { + mockery.enable({ + warnOnReplace: false, + warnOnUnregistered: false, + useCleanCache: true, + }); +}); + +test.beforeEach(() => { + mockery.resetCache(); +}); + +test.after(() => { + mockery.disable(); +}); + +function osMock(platform) { + return { + EOL: '\n', + platform: () => platform, + type: () => 'type', + release: () => 'release', + }; +} + +test('should use wmic on Windows when it is available', async t => { + mockery.registerMock('os', osMock('win32')); + mockery.registerMock('./wmic', cb => cb(null, [[0, 100], [100, 101]])); + mockery.registerMock('./powershell', () => { + t.fail('powershell should not be used when wmic succeeds'); + }); + + const get = require('../lib/get'); + + const result = await pify(get)(); + t.deepEqual(result, [[0, 100], [100, 101]]); + + mockery.deregisterMock('os'); + mockery.deregisterMock('./wmic'); + mockery.deregisterMock('./powershell'); +}); + +test('should fall back to powershell when wmic is missing on Windows', async t => { + const enoent = new Error('spawn wmic ENOENT'); + enoent.code = 'ENOENT'; + + mockery.registerMock('os', osMock('win32')); + mockery.registerMock('./wmic', cb => cb(enoent)); + mockery.registerMock('./powershell', cb => cb(null, [[0, 777], [777, 778]])); + + const get = require('../lib/get'); + + const result = await pify(get)(); + t.deepEqual(result, [[0, 777], [777, 778]]); + + mockery.deregisterMock('os'); + mockery.deregisterMock('./wmic'); + mockery.deregisterMock('./powershell'); +}); + +test('should not fall back to powershell on a non ENOENT wmic error', async t => { + const boom = new Error('wmic exploded'); + + mockery.registerMock('os', osMock('win32')); + mockery.registerMock('./wmic', cb => cb(boom)); + mockery.registerMock('./powershell', () => { + t.fail('powershell should not be used on a generic wmic error'); + }); + + const get = require('../lib/get'); + + const err = await t.throws(pify(get)()); + t.is(err.message, 'wmic exploded'); + + mockery.deregisterMock('os'); + mockery.deregisterMock('./wmic'); + mockery.deregisterMock('./powershell'); +}); diff --git a/test/powershell.js b/test/powershell.js new file mode 100644 index 0000000..734f3b5 --- /dev/null +++ b/test/powershell.js @@ -0,0 +1,66 @@ +import test from 'ava'; +import mockery from 'mockery'; + +import pify from 'pify'; + +import mocks from './helpers/mocks'; + +test.before(() => { + mockery.enable({ + warnOnReplace: false, + warnOnUnregistered: false, + useCleanCache: true, + }); +}); + +test.beforeEach(() => { + mockery.resetCache(); +}); + +test.after(() => { + mockery.disable(); +}); + +test('should parse powershell output on Windows', async t => { + const stdout = '0 777\r\n777 778\r\n0 779\r\n'; + + mockery.registerMock('child_process', { + spawn: () => mocks.spawn(stdout, '', null, 0, null), + }); + mockery.registerMock('os', { + EOL: '\n', + platform: () => 'win32', + type: () => 'type', + release: () => 'release', + }); + + const powershell = require('../lib/powershell'); + + const result = await pify(powershell)(); + t.deepEqual(result, [[0, 777], [777, 778], [0, 779]]); + + mockery.deregisterMock('child_process'); + mockery.deregisterMock('os'); +}); + +test('should ignore non numeric lines in powershell output', async t => { + const stdout = '\r\n0 777\r\nsome banner line\r\n777 778\r\n'; + + mockery.registerMock('child_process', { + spawn: () => mocks.spawn(stdout, '', null, 0, null), + }); + mockery.registerMock('os', { + EOL: '\n', + platform: () => 'win32', + type: () => 'type', + release: () => 'release', + }); + + const powershell = require('../lib/powershell'); + + const result = await pify(powershell)(); + t.deepEqual(result, [[0, 777], [777, 778]]); + + mockery.deregisterMock('child_process'); + mockery.deregisterMock('os'); +});