execCliSession.mjs

import cst from './_const.mjs'
import { spawn } from 'child_process'
import get from 'lodash-es/get.js'
import genPm from './genPm.mjs'
import isfun from './isfun.mjs'
import isarr from './isarr.mjs'
import isobj from './isobj.mjs'
import isestr from './isestr.mjs'
import ispint from './ispint.mjs'
import isp0int from './isp0int.mjs'
import cint from './cint.mjs'
import execProcessKillPid from './execProcessKillPid.mjs'
import execCliCore from './execCliCore.mjs'
import execSpawnError from './_execSpawnError.mjs'
import execThrownMsg from './_execThrownMsg.mjs'
import callUser from './_callUser.mjs'
import getTrace from './getTrace.mjs'


//buildSpawnArgs, buildSpawnEnv, 共用核心
let { buildSpawnArgs, buildSpawnEnv } = execCliCore


/**
 * 建立互動式stdio工作階段(session),用於stdio協定型之長駐CLI(如MCP之stdio伺服器、codex app-server等一行一則JSON之程式),與execCli「寫完input即關閉stdin並等待結束」之一次性契約不同,本函數維持stdin開啟,由呼叫端逐次write,直到呼叫stop()才關閉stdin並等待子進程自行退出,逾寬限期則以execProcessKillPid樹殺
 * Windows下npm全域安裝之命令為.cmd批次檔,內部經execCliCore.buildSpawnArgs解析為以node執行其JS入口,不使用shell:true,故樹殺可確實涵蓋子孫程序
 * 本函數不throw亦不reject,spawn失敗(如ENOENT)以onExit與stop()之結果物件回報,其error不含args與env之值;command、args、opt或其選項值不可讀(getter拋錯、已撤銷之Proxy等)時亦不spawn,同樣以onExit與stop()之結果回報(pid為null,error為'<錯誤碼>: <訊息>',無錯誤碼者為UNKNOWN,拋出非Error之值時訊息為該值之文字;onExit本身不可讀時僅以stop()回報)
 *
 * Unit Test: {@link https://github.com/yuda-lyu/wsemi/blob/master/test/execCliSession.test.mjs Github}
 * @memberOf wsemi
 * @param {String} command 輸入執行檔名稱字串,可為註冊系統的全域指令或執行檔路徑
 * @param {Array} [args=[]] 輸入參數字串陣列,預設[]
 * @param {Object} [opt={}] 輸入設定物件
 * @param {String} [opt.cwd=process.cwd()] 輸入子進程工作目錄字串,預設process.cwd();不存在或非資料夾時以onExit與stop()之結果回報error為'ENOENT: cwd is not an existing folder: <cwd>'
 * @param {Object} [opt.env=undefined] 輸入本次額外注入之環境變數物件,語意同execCli,預設undefined
 * @param {Number} [opt.timeoutMs=null] 輸入整體逾時毫秒正整數,逾時即樹殺並於結果標timeout:true,預設null代表不限制(工作階段為長駐,不沿用execCli之120000)
 * @param {Number} [opt.exitGraceMs=2000] 輸入stop()關閉stdin後等待子進程自行退出之寬限毫秒非負整數,逾期即樹殺,預設2000
 * @param {Number} [opt.killWaitMs=3000] 輸入樹殺後等待exit事件之上限毫秒非負整數,逾期仍未退出則於結果標exited:false並結束等待,預設3000
 * @param {Number} [opt.stderrKeep=4000] 輸入stderr保留尾端字元數非負整數,預設4000
 * @param {Function} [opt.onLine=undefined] 輸入stdout逐行回調函數,格式為(line)=>{},line不含換行(\\r\\n與\\n皆視為換行),子進程結束時殘餘之不完整行亦會發出,預設undefined
 * @param {Function} [opt.onStdout=undefined] 輸入stdout原始片段回調函數,格式為(chunk)=>{},已經UTF-8跨chunk解碼,供非行式協定(如LSP之Content-Length分幀)自行分幀,預設undefined
 * @param {Function} [opt.onStderr=undefined] 輸入stderr片段回調函數,格式為(chunk)=>{},預設undefined
 * @param {Function} [opt.onExit=undefined] 輸入子進程結束回調函數,格式為(result)=>{},result同stop()之回傳,預設undefined。onLine、onStdout、onStderr、onExit內拋出之錯誤不會被攔截,將成為未捕捉例外,請於回調內自行處理(該錯誤於回調返回後立即(微任務)原樣重拋,不影響其餘回調之送達、stop()之落定與結果)
 * @param {Function} [opt.onTrace=null] 輸入觀察回調函數,於內部事件發生當下同步呼叫,傳入一個凍結之事件物件{ type, ... },供測試以事件(而非等待時間)同步與診斷;欄位皆為原始值,不含stdout與stderr之內文(只有長度);type為'spawn'時為子進程已建立(含pid,未能啟動者為null),'data'時為收到一段已解碼之輸出(含stream為'stdout'或'stderr'、len為該段字元數;子進程結束時殘餘之解碼不通知,settle之後亦不通知),'write'時為一次寫入stdin定案(含ok為是否寫入成功、len為字元數;因已結束、已stop()或stdin不可寫而拒絕者len為null,已settle後之寫入不通知),'stop'時為首次呼叫stop()而即將關閉stdin並起算寬限(含ms為exitGraceMs;已settle或未能spawn者不通知,重複呼叫不通知),'grace'時為寬限期已到而子進程仍未退出,即將樹殺(含ms為exitGraceMs),'timeout'時為timeoutMs整體逾時計時器觸發而即將樹殺(含ms為timeoutMs),'kill'時為即將以execProcessKillPid樹殺並起算killWaitMs(含pid、ms為killWaitMs),'settle'時為工作階段即將定案而於onExit與stop()落定之前通知(含code為離開碼數字或null、signal為終止訊號字串或null、exited、timeout、killed、error字串,欄位同結果物件,exited為false代表樹殺後逾killWaitMs仍未退出);輸入不可讀、command無效、spawn失敗者只有settle事件;opt.onTrace屬性本身不可讀(getter拋錯)與其他回調相同,視為輸入不可讀;回調不帶接收者呼叫,其拋錯與回傳值皆忽略,不影響工作階段,預設null
 * @returns {Object} 回傳工作階段物件s,內含pid(子進程pid,spawn失敗為null)、exited(是否已結束布林值)、write(str)(寫入stdin,回傳Promise,resolve為是否寫入成功布林值,子進程已結束時靜默resolve(false))、writeLine(str)(補換行後寫入,str無法轉為字串時resolve(false))、stop()(關閉stdin並等待結束,回傳Promise,resolve回傳結果物件,內含code、signal、exited、timeout、killed、error、stderr、durationMs、pid;多次呼叫回傳同一Promise)
 * @example
 * //need test in nodejs
 *
 * async function test() {
 *
 *     let nodeBin = process.execPath
 *
 *     //逐行回顯程式, 收到EOF即結束
 *     let sc = `
 *         let rl = require('readline').createInterface({ input: process.stdin })
 *         rl.on('line', (l) => process.stdout.write('echo:' + l + '\\n'))
 *         rl.on('close', () => process.exit(0))
 *     `
 *
 *     let lines = []
 *     let s = execCliSession(nodeBin, ['-e', sc], {
 *         onLine: (line) => {
 *             lines.push(line)
 *         },
 *     })
 *     await s.writeLine('abc')
 *     await s.writeLine('中文')
 *     let r = await s.stop() //關閉stdin, 子進程收到EOF自行退出
 *     console.log(lines, r.code, r.exited, r.killed)
 *     // => [ 'echo:abc', 'echo:中文' ] 0 true false
 *
 *     //不理會EOF之程式, stop()逾寬限期即樹殺
 *     let s2 = execCliSession(nodeBin, ['-e', 'setInterval(() => {}, 1000)'], { exitGraceMs: 300 })
 *     let r2 = await s2.stop()
 *     console.log(r2.exited, r2.killed)
 *     // => true true
 *
 * }
 * test()
 *     .catch((err) => {
 *         console.log(err)
 *     })
 *
 */
function execCliSession(command, args = [], opt = {}) {

    //輸入之讀取與判型一律於try內: 拋錯者(command、args、opt或其選項值之getter拋錯、已撤銷之Proxy等不可讀之輸入)不throw, 記於errInput, 於下方以onExit與stop()之結果回報(同spawn同步錯誤)
    //是否出錯以布林旗標bErrInput判定, 不以errInput之值為哨兵(拋出之值可為null、undefined); 多處拋錯時記第一個
    //各選項先讀為暫存v再判型, 判型通過才指定, 拋錯時保留預設值; 回調於此即判型(非函數者為undefined), 其後監聽器與計時器內之判型不再觸及呼叫端之值
    let cwd = process.cwd()
    let envExtra
    let timeoutMs = null
    let exitGraceMs = 2000
    let killWaitMs = 3000
    let stderrKeep = 4000
    let onLine
    let onStdout
    let onStderr
    let onExit
    let onTrace
    let bCommand = false
    let bErrInput = false
    let errInput
    let setErrInput = (err) => {
        if (!bErrInput) {
            bErrInput = true
            errInput = err
        }
    }

    //opt, 不可讀者以空物件續讀(其後各項皆取預設值)
    try {
        if (!isobj(opt)) {
            opt = {}
        }
    }
    catch (err) {
        setErrInput(err)
        opt = {}
    }

    //callbacks, 先於其餘選項讀取且各自try: 可讀者保留(函數或undefined), 使其餘輸入不可讀時onExit仍可回報結果; 不可讀者為undefined(不被呼叫)
    let readFn = (key) => {
        try {
            let f = get(opt, key)
            return isfun(f) ? f : undefined
        }
        catch (err) {
            setErrInput(err)
            return undefined
        }
    }
    onLine = readFn('onLine')
    onStdout = readFn('onStdout')
    onStderr = readFn('onStderr')
    onExit = readFn('onExit')

    //trace, 觀察回調(opt.onTrace)同其他回調先讀取並判型, 不可讀者視為輸入不可讀; 未給時為null
    onTrace = readFn('onTrace')
    let trace = onTrace === undefined ? null : getTrace({ onTrace })

    //其餘輸入
    try {
        let v

        //command
        bCommand = isestr(command)

        //args
        if (!isarr(args)) {
            args = []
        }

        //cwd
        v = get(opt, 'cwd')
        cwd = isestr(v) ? v : process.cwd()

        //envExtra
        v = get(opt, 'env')
        envExtra = isobj(v) ? v : undefined

        //timeoutMs, 預設null不限制; 夾至計時器上限, 見_const.mjs
        v = get(opt, 'timeoutMs')
        timeoutMs = ispint(v) ? Math.min(v, cst.TIMER_TIME_MAX) : null

        //exitGraceMs, 夾至計時器上限, 見_const.mjs
        v = get(opt, 'exitGraceMs')
        exitGraceMs = Math.min(isp0int(v) ? v : 2000, cst.TIMER_TIME_MAX)

        //killWaitMs, 夾至計時器上限, 見_const.mjs
        v = get(opt, 'killWaitMs')
        killWaitMs = Math.min(isp0int(v) ? v : 3000, cst.TIMER_TIME_MAX)

        //stderrKeep, 轉為數字(支援字串型數字與包裝物件, 否則監聽器內之=== 0判定不成立而不截斷)
        v = get(opt, 'stderrKeep')
        stderrKeep = isp0int(v) ? cint(v) : 4000

    }
    catch (err) {
        setErrInput(err)
    }

    //startTime
    let startTime = Date.now()

    //state
    let settled = false
    let result = null
    let timedOut = false
    let killed = false
    let stopping = false
    let stderrTail = ''
    let lineBuf = ''
    let timer = null
    let graceTimer = null
    let killTimer = null
    let pmExit = genPm() //子進程結束(或確認無法結束)時resolve
    let pmStop = null

    //s
    let s = {
        pid: null,
        exited: false,
        write: null,
        writeLine: null,
        stop: null,
    }

    //settle, 只執行一次: 整理結果, 標記exited, 觸發onExit, 喚醒stop()
    let settle = (r) => {
        if (settled) {
            return
        }
        settled = true
        clearTimeout(timer)
        clearTimeout(graceTimer)
        clearTimeout(killTimer)
        result = {
            code: null,
            signal: null,
            exited: true,
            timeout: timedOut,
            killed,
            error: '',
            stderr: stderrTail,
            durationMs: Date.now() - startTime,
            pid: s.pid,
            ...r,
        }
        s.exited = true

        //settle事件, 於onExit與stop()落定之前通知
        if (trace) {
            trace('settle', {
                code: result.code,
                signal: result.signal,
                exited: result.exited,
                timeout: result.timeout,
                killed: result.killed,
                error: result.error,
            })
        }

        if (onExit !== undefined) { //回調已於入口判型(函數或undefined), 監聽器與計時器內一律以!== undefined判定, 不再判型呼叫端之值(值於入口後撤銷時判型會拋出而中斷收尾)
            callUser(onExit, result) //onExit拋錯不得中斷喚醒stop(), 否則stop()永久懸置
        }
        pmExit.resolve(result)
    }

    //stopNoProc, 未能spawn時之stop
    let stopNoProc = () => {
        if (pmStop) {
            return pmStop
        }
        pmStop = genPm()
        pmExit.then((r) => {
            pmStop.resolve(r)
        })
        return pmStop
    }

    //輸入不可讀: 不spawn, 同spawn同步錯誤以timer脫勾後settle(error為'碼: 訊息', pid為null), settle經callUser呼叫可讀之onExit
    if (bErrInput) {
        let e = execSpawnError(errInput)
        let m = execThrownMsg(e, errInput)
        s.write = async () => false
        s.writeLine = async () => false
        s.stop = stopNoProc
        setTimeout(() => {
            settle({ error: `${e.code}: ${m}` })
        }, 1)
        return s
    }

    //command必填字串: 不spawn, 以timer脫勾後settle使onExit於本函數回傳後才觸發
    if (!bCommand) {
        s.write = async () => false
        s.writeLine = async () => false
        s.stop = stopNoProc
        setTimeout(() => {
            settle({ error: 'command 須為非空字串' })
        }, 1)
        return s
    }

    //spawn, buildSpawnArgs之拋錯亦於此回報
    let proc
    try {
        let envSpawn = buildSpawnEnv(envExtra)
        let sp = buildSpawnArgs(command, args, { cwd, env: envSpawn })
        proc = spawn(sp.file, sp.args, {
            cwd,
            stdio: ['pipe', 'pipe', 'pipe'],
            windowsHide: true, //執行的主程序若沒有主控台調用執行程序就不會有視窗, 但若通過pm2執行會有, 須設定windowsHide=true
            env: envSpawn,
            ...sp.opt,
        })
    }
    catch (err) {
        //spawn同步拋錯(如EPERM、ENAMETOOLONG、參數檢查錯誤、不存在之.cmd/.bat), 訊息經execSpawnError去除參數值, cwd不存在時改指cwd
        let e = execSpawnError(err, { cwd })
        s.write = async () => false
        s.writeLine = async () => false
        s.stop = stopNoProc
        setTimeout(() => {
            settle({ error: `${e.code}: ${e.message}` })
        }, 1)
        return s
    }
    s.pid = proc.pid || null
    if (trace) {
        trace('spawn', { pid: s.pid })
    }

    //error, 須於存取stdio前監聽: node對EMFILE、ENFILE不建立stdio即於nextTick發出'error', 先存取會拋TypeError且'error'無人監聽而拉倒程序
    //  可能為'找不到可執行檔'(ENOENT), '權限不足', '系統資源用盡'等, 訊息經execSpawnError去除參數值
    proc.on('error', (err) => {
        let e = execSpawnError(err, { cwd })
        settle({ error: `${e.code}: ${e.message}` })
    })

    //stdio, EMFILE、ENFILE時node未建立(undefined), 由上方error回報
    if (!proc.stdout || !proc.stderr || !proc.stdin) {
        s.write = async () => false
        s.writeLine = async () => false
        s.stop = stopNoProc
        return s
    }

    //TextDecoder, 跨chunk邊界正確解碼UTF-8, 避免多位元組中文字元被切成亂碼(U+FFFD)
    let stdoutDecoder = new TextDecoder('utf-8')
    let stderrDecoder = new TextDecoder('utf-8')

    //emitLines, 以\n切行, 去尾端\r, 殘餘保留至下次; flush時把殘餘不完整行一併發出
    let emitLines = (str, flush) => {
        lineBuf += str
        let idx = lineBuf.indexOf('\n')
        while (idx >= 0) {
            let line = lineBuf.slice(0, idx)
            lineBuf = lineBuf.slice(idx + 1)
            if (line.endsWith('\r')) {
                line = line.slice(0, -1)
            }
            if (onLine !== undefined) {
                callUser(onLine, line) //拋錯不得中斷同一chunk其餘行之送達(否則延至下個chunk或收尾, 如JSON-RPC之回應卡至逾時)
            }
            idx = lineBuf.indexOf('\n')
        }
        if (flush && lineBuf !== '') {
            let line = lineBuf
            lineBuf = ''
            if (line.endsWith('\r')) {
                line = line.slice(0, -1)
            }
            if (onLine !== undefined) {
                callUser(onLine, line)
            }
        }
    }

    //appendStderr, 保留尾端stderrKeep字元
    let appendStderr = (str) => {
        if (stderrKeep === 0) {
            stderrTail = ''
            return
        }
        stderrTail = (stderrTail + str).slice(-stderrKeep)
    }

    //stdout data
    proc.stdout.on('data', (chunk) => {
        let str = stdoutDecoder.decode(chunk, { stream: true })
        if (!str) {
            return
        }
        if (trace && !settled) {
            trace('data', { stream: 'stdout', len: str.length })
        }
        if (onStdout !== undefined) {
            callUser(onStdout, str) //拋錯不得使該chunk不進onLine
        }
        emitLines(str, false)
    })

    //stderr data
    proc.stderr.on('data', (chunk) => {
        let str = stderrDecoder.decode(chunk, { stream: true })
        if (!str) {
            return
        }
        if (trace && !settled) {
            trace('data', { stream: 'stderr', len: str.length })
        }
        if (onStderr !== undefined) {
            callUser(onStderr, str)
        }
        appendStderr(str)
    })

    //stdin, 子進程提早關閉stdin時write/end會觸發EPIPE, 需listen'error'才不會拋unhandled event
    proc.stdin.on('error', () => {})

    //kill, 樹殺並等待exit, 逾killWaitMs仍未退出則以exited:false結束等待
    let kill = (errorIfStuck) => {
        killed = true
        if (trace) {
            trace('kill', { pid: s.pid, ms: killWaitMs })
        }
        execProcessKillPid(proc.pid)
            .catch(() => {}) //pid可能已結束, 忽略error
        killTimer = setTimeout(() => {
            if (settled) {
                return
            }
            settle({ code: null, exited: false, error: errorIfStuck })
        }, killWaitMs)
    }

    //timer, 整體逾時
    if (timeoutMs !== null) {
        timer = setTimeout(() => {
            if (settled) {
                return
            }
            timedOut = true
            if (trace) {
                trace('timeout', { ms: timeoutMs })
            }
            kill(`TIMEOUT after ${timeoutMs / 1000}s(子進程未能結束)`)
        }, timeoutMs)
    }

    //close
    proc.on('close', (code, signal) => {
        if (settled) {
            return
        }

        //flush decoder殘餘與不完整行; 回調經callUser, 拋錯不得中斷收尾(否則不settle, stop()於killWaitMs後誤報STOP_TIMEOUT且onExit不被呼叫)
        let fOut = stdoutDecoder.decode()
        if (fOut) {
            if (onStdout !== undefined) {
                callUser(onStdout, fOut)
            }
            emitLines(fOut, false)
        }
        emitLines('', true)
        let fErr = stderrDecoder.decode()
        if (fErr) {
            if (onStderr !== undefined) {
                callUser(onStderr, fErr)
            }
            appendStderr(fErr)
        }

        //error, 自行stop()所致之樹殺不視為錯誤
        let error = ''
        if (timedOut) {
            error = `TIMEOUT after ${timeoutMs / 1000}s`
        }
        else if (!killed) {
            if (signal) {
                error = `Signal: ${signal}`
            }
            else if (code !== 0) {
                error = `Exit code ${code}`
            }
        }

        settle({ code, signal, error })
    })

    //write
    s.write = (str) => {
        let pm = genPm()
        if (settled || stopping || proc.stdin.destroyed || !proc.stdin.writable) {
            if (trace && !settled) {
                trace('write', { ok: false, len: null })
            }
            pm.resolve(false)
            return pm
        }
        try {
            let sw = String(str)
            proc.stdin.write(sw, 'utf8', (err) => {
                if (trace && !settled) {
                    trace('write', { ok: !err, len: sw.length })
                }
                pm.resolve(!err)
            })
        }
        catch {
            if (trace && !settled) {
                trace('write', { ok: false, len: null })
            }
            pm.resolve(false)
        }
        return pm
    }

    //writeLine
    s.writeLine = (str) => {
        //String須在try內, 與write對稱: 無法轉字串者(如Object.create(null)、已撤銷之Proxy)resolve(false)而不同步拋錯
        let line
        try {
            line = String(str) + '\n'
        }
        catch {
            let pm = genPm()
            pm.resolve(false)
            return pm
        }
        return s.write(line)
    }

    //stop, 關閉stdin → 等exitGraceMs → 未退出即樹殺 → 等exit; 多次呼叫回傳同一Promise
    s.stop = () => {
        if (pmStop) {
            return pmStop
        }
        pmStop = genPm()
        if (settled) {
            pmStop.resolve(result)
            return pmStop
        }
        stopping = true
        if (trace) {
            trace('stop', { ms: exitGraceMs })
        }
        try {
            proc.stdin.end()
        }
        catch {}
        graceTimer = setTimeout(() => {
            if (settled) {
                return
            }
            if (trace) {
                trace('grace', { ms: exitGraceMs })
            }
            kill('STOP_TIMEOUT(子進程未能結束)')
        }, exitGraceMs)
        pmExit.then((r) => {
            pmStop.resolve(r)
        })
        return pmStop
    }

    return s
}


export default execCliSession