From 740635fd19440286ccebc277c1d82f78ae3e7e59 Mon Sep 17 00:00:00 2001 From: Anton Golub Date: Wed, 8 Apr 2026 18:43:00 +0300 Subject: [PATCH 1/3] refactor: simplify internals --- package-lock.json | 82 ++++++---------- package.json | 7 +- src/main/ts/ps.ts | 237 +++++++++++++++++++++------------------------- 3 files changed, 139 insertions(+), 187 deletions(-) diff --git a/package-lock.json b/package-lock.json index c2721ec..5d90a75 100644 --- a/package-lock.json +++ b/package-lock.json @@ -697,9 +697,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -717,9 +714,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -737,9 +731,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -757,9 +748,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -777,9 +765,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -797,9 +782,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -817,9 +799,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -837,9 +816,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1196,22 +1172,6 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/chokidar": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", - "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", - "dev": true, - "license": "MIT", - "dependencies": { - "readdirp": "^5.0.0" - }, - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/cliui": { "version": "8.0.1", "dev": true, @@ -1900,6 +1860,21 @@ "node": "^20.19.0 || >=22.12.0" } }, + "node_modules/mocha/node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/mocha/node_modules/escape-string-regexp": { "version": "4.0.0", "dev": true, @@ -1911,6 +1886,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/mocha/node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/mocha/node_modules/supports-color": { "version": "8.1.1", "dev": true, @@ -2102,20 +2090,6 @@ ], "license": "MIT" }, - "node_modules/readdirp": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", - "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/require-directory": { "version": "2.1.1", "dev": true, diff --git a/package.json b/package.json index 4ba8f73..f5cc505 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "test": "concurrently 'npm:test:lint' 'npm:test:unit' 'npm:test:size' && npm run test:legacy", "test:lint": "oxlint -c oxlintrc.json src/main/ts src/test/ts", "test:unit": "c8 -r lcov -r text -o target/coverage -x src/scripts -x src/test -x target node --loader ts-node/esm --experimental-specifier-resolution=node src/scripts/test.mjs", - "test:legacy": "node ./node_modules/mocha/bin/mocha -t 0 -R spec src/test/legacy/test.cjs", + "test:legacy": "mocha -t 0 -R spec src/test/legacy/test.cjs", "test:size": "size-limit", "publish:draft": "npm run build && npm publish --no-git-tag-version" }, @@ -76,5 +76,8 @@ "type": "git", "url": "git://github.com/webpod/ps.git" }, - "license": "MIT" + "license": "MIT", + "overrides": { + "chokidar": "^4.0.3" + } } diff --git a/src/main/ts/ps.ts b/src/main/ts/ps.ts index 86c0cee..4c85015 100644 --- a/src/main/ts/ps.ts +++ b/src/main/ts/ps.ts @@ -4,22 +4,25 @@ import os from 'node:os' import { parse, type TIngridResponse } from '@webpod/ingrid' import { exec, type TSpawnCtx } from 'zurk/spawn' +const noop = () => {} + const IS_WIN = process.platform === 'win32' const IS_WIN2025_PLUS = IS_WIN && Number.parseInt(os.release().split('.')[2], 10) >= 26_000 +const LOOKUP_FLOW = IS_WIN ? IS_WIN2025_PLUS ? 'pwsh' : 'wmic' : 'ps' const LOOKUPS: Record TIngridResponse }> = { wmic: { cmd: 'wmic process get ProcessId,ParentProcessId,CommandLine', args: [], - parse: (stdout) => parse(removeWmicPrefix(stdout), { format: 'win' }) + parse: (stdout) => parse(removeWmicPrefix(stdout), { format: 'win' }), }, ps: { cmd: 'ps', args: ['-eo', 'pid,ppid,args'], - parse: (stdout) => parse(stdout, { format: 'unix' }) + parse: (stdout) => parse(stdout, { format: 'unix' }), }, pwsh: { cmd: 'pwsh', @@ -39,8 +42,6 @@ const LOOKUPS: Record void * Supports both promise and callback styles. */ export const lookup = (query: TPsLookupQuery = {}, cb: TPsLookupCallback = noop): Promise => - _lookup({ query, cb, sync: false }) as Promise + runLookup(query, cb, false) /** Synchronous version of {@link lookup}. */ export const lookupSync = (query: TPsLookupQuery = {}, cb: TPsLookupCallback = noop): TPsLookupEntry[] => - _lookup({ query, cb, sync: true }) + runLookup(query, cb, true) lookup.sync = lookupSync -const _lookup = ({ query = {}, cb = noop, sync = false }: { - sync?: boolean - cb?: TPsLookupCallback - query?: TPsLookupQuery -}) => { - const { promise, resolve, reject } = sync ? makeSyncDeferred([]) : makeDeferred() - const result: TPsLookupEntry[] = [] - const { parse: parseOutput, cmd, args: defaultArgs } = LOOKUPS[lookupFlow] +function runLookup(query: TPsLookupQuery, cb: TPsLookupCallback, sync: true): TPsLookupEntry[] +function runLookup(query: TPsLookupQuery, cb: TPsLookupCallback, sync: false): Promise +function runLookup(query: TPsLookupQuery, cb: TPsLookupCallback, sync: boolean): TPsLookupEntry[] | Promise { + const { parse: parseOutput, cmd, args: defaultArgs } = LOOKUPS[LOOKUP_FLOW] const args = !IS_WIN && query.psargs ? query.psargs.split(/\s+/) : defaultArgs + let result: TPsLookupEntry[] = [] + let error: unknown - const callback: TSpawnCtx['callback'] = (err, { stdout }) => { - if (err) { - reject(err) - cb(err) - return - } - result.push(...filterProcessList(normalizeOutput(parseOutput(stdout)), query)) - resolve(result) - cb(null, result) + const handle: TSpawnCtx['callback'] = (err, { stdout }) => { + if (err) { error = err; return } + result = filterProcessList(normalizeOutput(parseOutput(stdout)), query) } - exec({ cmd, args, callback, sync, run(cb) { cb() } }) + if (sync) { + exec({ cmd, args, sync: true, callback: handle, run(c) { c() } }) + cb(error ?? null, error ? undefined : result) + if (error) throw error + return result + } - return Object.assign(promise, result) + return new Promise((resolve, reject) => { + exec({ + cmd, args, sync: false, run(c) { c() }, + callback(err, ctx) { + handle(err, ctx) + if (error) { cb(error); reject(error) } + else { cb(null, result); resolve(result) } + }, + }) + }) } /** Returns child processes of the given parent pid. */ -export const tree = async (opts?: string | number | TPsTreeOpts, cb?: TPsLookupCallback): Promise => - _tree({ opts, cb }) - -/** Synchronous version of {@link tree}. */ -export const treeSync = (opts?: string | number | TPsTreeOpts, cb?: TPsLookupCallback): TPsLookupEntry[] => - _tree({ opts, cb, sync: true }) as TPsLookupEntry[] - -tree.sync = treeSync - -const _tree = ({ cb = noop, opts, sync = false }: { - opts?: string | number | TPsTreeOpts - cb?: TPsLookupCallback - sync?: boolean -}) => { - if (typeof opts === 'string' || typeof opts === 'number') { - return _tree({ opts: { pid: opts }, cb, sync }) - } - - const onData = (all: TPsLookupEntry[]) => { - if (opts === undefined) return all - const list = pickTree(all, opts.pid, opts.recursive ?? false) +export const tree = async (opts?: string | number | TPsTreeOpts, cb: TPsLookupCallback = noop): Promise => { + try { + const list = pickFromTree(await lookup(), opts) cb(null, list) return list - } - - const onError = (err: unknown) => { + } catch (err) { cb(err) throw err } +} +/** Synchronous version of {@link tree}. */ +export const treeSync = (opts?: string | number | TPsTreeOpts, cb: TPsLookupCallback = noop): TPsLookupEntry[] => { try { - const all = _lookup({ sync }) - return sync - ? onData(all) - : (all as Promise).then(onData, onError) + const list = pickFromTree(lookupSync(), opts) + cb(null, list) + return list } catch (err) { cb(err) - return Promise.reject(err) + throw err } } -/** - * Returns a `lookup()` snapshot started at or after `since`, dedupes concurrent callers. - * Lets parallel `kill()` polls share a single `ps` invocation: join the in-flight one - * if it's fresh enough, otherwise wait for the next queued one. - */ -type TSnapshot = { startedAt: number, list: TPsLookupEntry[] } -let inflight: { startedAt: number, promise: Promise } | null = null -let queued: Promise | null = null -const sharedSnapshot = (since: number): Promise => { - if (inflight && inflight.startedAt >= since) return inflight.promise - if (queued) return queued - const after = inflight?.promise.catch(noop) ?? Promise.resolve() - return queued = after.then(() => { - queued = null - const startedAt = Date.now() - const promise = lookup().then(list => ({ startedAt, list })) - inflight = { startedAt, promise } - return promise.finally(() => { inflight = inflight?.promise === promise ? null : inflight }) - }) +tree.sync = treeSync + +const pickFromTree = (all: TPsLookupEntry[], opts?: string | number | TPsTreeOpts): TPsLookupEntry[] => { + if (opts === undefined) return all + const { pid, recursive = false } = typeof opts === 'object' ? opts : { pid: opts } + return pickTree(all, pid, recursive) } export const pickTree = (list: TPsLookupEntry[], pid: string | number, recursive = false): TPsLookupEntry[] => { const children = list.filter(p => p.ppid === String(pid)) - return [ - ...children, - ...children.flatMap(p => recursive ? pickTree(list, p.pid, true) : []) - ] + return recursive + ? children.flatMap(p => [p, ...pickTree(list, p.pid, true)]) + : children } /** @@ -192,43 +167,64 @@ export const kill = (pid: string | number, opts?: TPsNext | TPsKillOptions | TPs if (typeof opts === 'function') return kill(pid, undefined, opts) if (typeof opts === 'string' || typeof opts === 'number') return kill(pid, { signal: opts }, next) - const { promise, resolve, reject } = makeDeferred() const { timeout = 30, signal = 'SIGTERM', interval = 200 } = opts || {} const sPid = String(pid) - let done = false - const state: { timer?: NodeJS.Timeout } = {} - const settle = (err?: unknown) => { - if (done) return - done = true - clearTimeout(state.timer) - if (err) reject(err) - else resolve(pid) - next?.(err ?? null, pid) - } - try { - process.kill(+pid, signal) - } catch (e) { - settle(e) - return promise - } + return new Promise((resolve, reject) => { + let done = false + const settle = (err?: unknown) => { + if (done) return + done = true + clearTimeout(timer) + if (err) reject(err) + else resolve(pid) + next?.(err ?? null, pid) + } - let since = Date.now() - state.timer = setTimeout(() => settle(new Error('Kill process timeout')), timeout * 1000) + const timer = setTimeout(() => settle(new Error('Kill process timeout')), timeout * 1000) - const poll = (): unknown => - sharedSnapshot(since).then(({ startedAt, list }) => { - if (done) return - since = startedAt + 1 - if (list.some(p => p.pid === sPid)) { - setTimeout(poll, Math.max(0, startedAt + interval - Date.now())) - } else { - settle() - } - }, settle) + try { + process.kill(+pid, signal) + } catch (e) { + settle(e) + return + } + + let since = Date.now() + const poll = (): unknown => + sharedSnapshot(since).then(({ startedAt, list }) => { + if (done) return + since = startedAt + 1 + if (list.some(p => p.pid === sPid)) { + setTimeout(poll, Math.max(0, startedAt + interval - Date.now())) + } else { + settle() + } + }, settle) + + poll() + }) +} - poll() - return promise +/** + * Returns a `lookup()` snapshot started at or after `since`, dedupes concurrent callers. + * Lets parallel `kill()` polls share a single `ps` invocation: join the in-flight one + * if it's fresh enough, otherwise wait for the next queued one. + */ +type TSnapshot = { startedAt: number, list: TPsLookupEntry[] } +let inflight: { startedAt: number, promise: Promise } | null = null +let queued: Promise | null = null +const sharedSnapshot = (since: number): Promise => { + if (inflight && inflight.startedAt >= since) return inflight.promise + if (queued) return queued + const after = inflight?.promise.catch(noop) ?? Promise.resolve() + return queued = after.then(() => { + queued = null + const startedAt = Date.now() + const promise = lookup().then(list => ({ startedAt, list })) + inflight = { startedAt, promise } + return promise.finally(() => { if (inflight?.promise === promise) inflight = null }) + }) } export const normalizeOutput = (data: TIngridResponse): TPsLookupEntry[] => @@ -284,24 +280,3 @@ const isBin = (f: string): boolean => { return false } } - -type Deferred = { - promise: Promise - resolve: (value?: T | PromiseLike) => void - reject: (reason?: E) => void -} - -const makeDeferred = (): Deferred => { - let resolve!: Deferred['resolve'] - let reject!: Deferred['reject'] - const promise = new Promise((res, rej) => { resolve = res as Deferred['resolve']; reject = rej }) - return { resolve, reject, promise } -} - -const makeSyncDeferred = (result: T): Deferred => ({ - promise: result as any, - resolve: () => {}, - reject(e) { throw e }, -}) - -const noop = () => {} From 2a2dd8eb3c241619eec3c4a77ebe3c95aa7842d8 Mon Sep 17 00:00:00 2001 From: Anton Golub Date: Wed, 8 Apr 2026 18:58:07 +0300 Subject: [PATCH 2/3] refactor: single kill confirmation queue --- .github/workflows/ci.yaml | 2 ++ src/main/ts/ps.ts | 68 ++++++++++++++++++++++----------------- 2 files changed, 41 insertions(+), 29 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 1da4f64..43c14e9 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -148,6 +148,8 @@ jobs: with: name: build - run: npm install --no-package-lock + # npm 6 (shipped with Node 14) ignores `overrides`, so pin chokidar manually + - run: npm install --no-package-lock --no-save chokidar@^4 - run: npm run test:legacy smoke-os: diff --git a/src/main/ts/ps.ts b/src/main/ts/ps.ts index 4c85015..c348355 100644 --- a/src/main/ts/ps.ts +++ b/src/main/ts/ps.ts @@ -172,14 +172,17 @@ export const kill = (pid: string | number, opts?: TPsNext | TPsKillOptions | TPs return new Promise((resolve, reject) => { let done = false + const entry: TKillEntry = { pid: sPid, registered: 0, interval, settle: noop } const settle = (err?: unknown) => { if (done) return done = true clearTimeout(timer) + killPending.delete(entry) if (err) reject(err) else resolve(pid) next?.(err ?? null, pid) } + entry.settle = settle const timer = setTimeout(() => settle(new Error('Kill process timeout')), timeout * 1000) @@ -190,40 +193,47 @@ export const kill = (pid: string | number, opts?: TPsNext | TPsKillOptions | TPs return } - let since = Date.now() - const poll = (): unknown => - sharedSnapshot(since).then(({ startedAt, list }) => { - if (done) return - since = startedAt + 1 - if (list.some(p => p.pid === sPid)) { - setTimeout(poll, Math.max(0, startedAt + interval - Date.now())) - } else { - settle() - } - }, settle) - - poll() + entry.registered = Date.now() + killPending.add(entry) + scheduleKillTick() }) } /** - * Returns a `lookup()` snapshot started at or after `since`, dedupes concurrent callers. - * Lets parallel `kill()` polls share a single `ps` invocation: join the in-flight one - * if it's fresh enough, otherwise wait for the next queued one. + * Shared kill-confirmation loop. A single `lookup()` per tick serves *all* pending kills + * registered before the tick's snapshot started — so a flood of kills can never indefinitely + * postpone any single confirmation, and we never spawn more than one `ps` per tick. */ -type TSnapshot = { startedAt: number, list: TPsLookupEntry[] } -let inflight: { startedAt: number, promise: Promise } | null = null -let queued: Promise | null = null -const sharedSnapshot = (since: number): Promise => { - if (inflight && inflight.startedAt >= since) return inflight.promise - if (queued) return queued - const after = inflight?.promise.catch(noop) ?? Promise.resolve() - return queued = after.then(() => { - queued = null - const startedAt = Date.now() - const promise = lookup().then(list => ({ startedAt, list })) - inflight = { startedAt, promise } - return promise.finally(() => { if (inflight?.promise === promise) inflight = null }) +type TKillEntry = { pid: string, registered: number, interval: number, settle: (err?: unknown) => void } +const killPending = new Set() +let killTickTimer: NodeJS.Timeout | null = null +let killTickRunning = false + +const scheduleKillTick = (lastStart = 0): void => { + if (killTickTimer || killTickRunning || killPending.size === 0) return + let minInterval = Infinity + for (const k of killPending) if (k.interval < minInterval) minInterval = k.interval + const delay = lastStart === 0 ? 0 : Math.max(0, lastStart + minInterval - Date.now()) + killTickTimer = setTimeout(runKillTick, delay) +} + +const runKillTick = (): void => { + killTickTimer = null + if (killPending.size === 0) return + killTickRunning = true + const startedAt = Date.now() + lookup().then(list => { + const alive = new Set(list.map(p => p.pid)) + for (const k of killPending) { + // Snapshot predates this kill's process.kill — can't trust it, wait for next tick + if (k.registered >= startedAt) continue + if (!alive.has(k.pid)) k.settle() + } + killTickRunning = false + scheduleKillTick(startedAt) + }, err => { + for (const k of killPending) k.settle(err) + killTickRunning = false }) } From 80cca595639fdcd5674e9df6b588d5d7e972c392 Mon Sep 17 00:00:00 2001 From: Anton Golub Date: Wed, 8 Apr 2026 19:23:05 +0300 Subject: [PATCH 3/3] test: fix node14 legacy test --- .github/workflows/ci.yaml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 43c14e9..3d401f8 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -148,8 +148,9 @@ jobs: with: name: build - run: npm install --no-package-lock - # npm 6 (shipped with Node 14) ignores `overrides`, so pin chokidar manually - - run: npm install --no-package-lock --no-save chokidar@^4 + # npm 6 (shipped with Node 14) ignores `overrides` and nests chokidar@5 under mocha; + # replace it with the CJS-compatible chokidar@4 + - run: rm -rf node_modules/mocha/node_modules/chokidar && npm install --no-package-lock --no-save --prefix node_modules/mocha chokidar@^4 - run: npm run test:legacy smoke-os: