🌳
pt0/serverF/libFs/live1SpawnF.mts
1import * as _ from 'lodash-es'
2import { spawn } from 'child_process'
4import fs from 'fs'
6import { ptDir } from '../ptDirF.mts'
8// When running under ptnode, write full output to its log file even when isQuiet
9let ptnodeLogStream: fs.WriteStream | null = null
10const getPtnodeLogStream = () => {
11 const logPath = process.env.PTNODE_LOG_PATH
12 if (!logPath) return null
13 if (!ptnodeLogStream) {
14 ptnodeLogStream = fs.createWriteStream(logPath, {flags: 'a'})
15 }
16 return ptnodeLogStream
19export const liveSpawn = async ({cmd, cwd, isQuiet, quiet, onDataFnc, noOutCmd, printCmd, timeoutAfterSec, signal}: {
20 cmd: string, cwd?: string, isQuiet?: boolean, quiet?: boolean
21 onDataFnc?: (data: string) => string | undefined
22 noOutCmd?: boolean
23 printCmd?: string
24 timeoutAfterSec?: number
25 signal?: AbortSignal
26}) => {
27 isQuiet ||= quiet
28 // isQuiet:true may truncate stdout for some cmds (eg kubectl logs). Use eptKubeCli/livePtySpawn instead.
29 if (!signal && timeoutAfterSec) {
30 signal = AbortSignal.timeout(timeoutAfterSec * 1000)
31 }
32 let exited = false
34 const displayCmd = (printCmd || cmd).replaceAll(`${ptDir}/`, '')
35 if (!noOutCmd) {
36 console.log(displayCmd)
37 }
39 const ptnodeLog = isQuiet ? getPtnodeLogStream() : null
40 if (ptnodeLog && !noOutCmd) {
41 ptnodeLog.write(`$ ${displayCmd}\n`)
42 }
44 // detached: true creates a new process group so we can kill all child processes together
45 // IMPORTANT: cleanup relies on process.kill(-pid) working - if this fails, zombie processes accumulate
46 const spawnObj = spawn(cmd, {shell: true, detached: true, cwd})
48 signal?.addEventListener('abort', (...args) => {
49 if (exited) return
51 if (isVeryVerbose) {
52 betVerboseLog('killing..', {exited}, cmd)
53 }
54 // Kill entire process group (negative pid) to ensure all child processes are killed
55 // EPERM/ESRCH = process already exited or group doesn't exist - expected during cleanup races
56 try { process.kill(-spawnObj.pid!, 'SIGKILL') } catch (e) {
57 const err = e as NodeJS.ErrnoException
58 if (err.code !== 'EPERM' && err.code !== 'ESRCH')
59 console.error('liveSpawn kill failed:', {pid: spawnObj.pid, cmd: cmd.slice(0, 80), error: e})
60 }
61 })
63 let stdout = ''
64 let stderr = ''
66 const mkOnData = (stdoutErrS: 'stdout' | 'stderr') => {
67 const spawnIo = spawnObj[stdoutErrS]
68 spawnIo.setEncoding('utf8')
69 const procIo = process[stdoutErrS]
70 spawnIo.on('data', (data) => {
71 if (onDataFnc) {
72 const ret = onDataFnc(data)
73 if (ret !== undefined) {
74 data = ret
75 }
76 }
78 if (stdoutErrS === 'stdout') stdout += data
79 else stderr += data
81 if (!isQuiet) {
82 procIo.write(data.toString())
83 }
84 if (ptnodeLog) {
85 ptnodeLog.write(data.toString())
86 }
87 })
88 }
89 mkOnData('stdout')
90 mkOnData('stderr')
92 return await new Promise<{exitCode: number | null, stdout: string, stderr: string, isSuccess: boolean}>((resolv) => {
93 spawnObj.on('exit', (exitCode) => {
94 exited = true
95 const isSuccess = exitCode === 0
96 resolv({exitCode, stdout, stderr, isSuccess})
97 })
99 signal?.addEventListener('abort', () => {
100 if (!exited) {
101 resolv({exitCode: 123, stdout, stderr, isSuccess: false})
102 }
103 })
104 })