pmTimeout.mjs

import cst from './_const.mjs'
import get from 'lodash-es/get.js'
import genPm from './genPm.mjs'
import ispm from './ispm.mjs'
import isestr from './isestr.mjs'
import isp0int from './isp0int.mjs'
import getTrace from './getTrace.mjs'


/**
 * 為Promise添加逾時限制
 *
 * 以Promise.race令pm與計時器競速,pm先完成則原樣回傳其resolve或reject之結果,逾時則reject一個Error
 * 該Error之message為'timeout[ms數ms]'(有label時置於前綴),並帶有code為'TIMEOUT'與timeout為逾時毫秒數等欄位,供呼叫端辨別為逾時而非pm自身之錯誤
 * 注意JavaScript無法中止已啟動之Promise,逾時僅代表不再等待其結果,pm內之工作仍會繼續執行至自然結束,若需一併中止內部工作(如子進程)須由pm自行處理
 * 本函數為async function,參數錯誤亦以reject傳出而非同步throw,故可與逾時錯誤一併由.catch或try/catch接住
 *
 * Unit Test: {@link https://github.com/yuda-lyu/wsemi/blob/master/test/pmTimeout.test.mjs Github}
 * @memberOf wsemi
 * @param {Promise} pm 輸入Promise物件,非Promise時reject錯誤'invalid pm'
 * @param {Integer} ms 輸入逾時毫秒數,須為非負整數,為0代表下一輪事件迴圈即逾時,其餘reject錯誤'invalid ms[ms數]'
 * @param {Object} [opt={}] 輸入設定物件
 * @param {String} [opt.label=''] 輸入識別字串,將置於逾時錯誤訊息之前綴以利辨識來源,非有效字串時回退為空字串,預設''
 * @param {Function} [opt.onTrace=null] 輸入觀察回調函數,於內部事件發生當下同步呼叫,傳入一個凍結之事件物件{ type, ... },供測試以事件(而非等待時間)同步與診斷;type為'start'時為已啟動逾時計時器(含ms為夾至上限後之逾時毫秒數),'timeout'時為逾時計時器已到期而即將reject逾時錯誤(含ms),'settle'時為本函數即將resolve或reject(含by為'pm'代表pm先完成、'timeout'代表逾時勝出、'invalid'代表輸入檢核失敗,ok為pm先完成時是否resolve,逾時與檢核失敗皆為false);每次呼叫恰通知一次settle,檢核失敗時不通知start與timeout;回調不帶接收者呼叫,其拋錯與回傳值皆忽略,不影響逾時判定,預設null
 * @returns {Promise} 回傳Promise,pm先完成則resolve或reject其結果,逾時則reject一個code為'TIMEOUT'之Error
 * @example
 * //need test in nodejs
 *
 * async function test() {
 *
 *     //pm先完成則原樣回傳其結果
 *     let r1 = await pmTimeout(delay(10).then(() => 'ok'), 1000)
 *     console.log('r1', r1)
 *     // => r1 ok
 *
 *     //逾時則reject一個code為'TIMEOUT'之Error
 *     try {
 *         await pmTimeout(delay(3000), 100)
 *     }
 *     catch (err) {
 *         console.log('r2', err.message, err.code, err.timeout)
 *         // => r2 timeout[100ms] TIMEOUT 100
 *     }
 *
 *     //有label時置於訊息前綴
 *     try {
 *         await pmTimeout(delay(3000), 100, { label: 'fetchUser' })
 *     }
 *     catch (err) {
 *         console.log('r3', err.message)
 *         // => r3 fetchUser timeout[100ms]
 *     }
 *
 *     //pm自身之reject原樣傳出, 其code非'TIMEOUT'故可與逾時區分
 *     try {
 *         await pmTimeout(Promise.reject(new Error('mine')), 1000)
 *     }
 *     catch (err) {
 *         console.log('r4', err.message, err.code)
 *         // => r4 mine undefined
 *     }
 *
 * }
 * test()
 *     .catch((err) => {
 *         console.log(err)
 *     })
 *
 */
async function pmTimeout(pm, ms, opt = {}) {

    //trace, 觀察回調(opt.onTrace), 未給時為null
    let trace = getTrace(opt)

    //check, pm與ms給錯視為程式撰寫錯誤
    //本函數為async function, 故此處throw會轉為reject傳出, 與pm*家族之錯誤傳遞管道一致(見pmSeries/pmChain)
    if (!ispm(pm)) {
        if (trace) {
            trace('settle', { by: 'invalid', ok: false })
        }
        throw new Error(`invalid pm`)
    }

    //check
    if (!isp0int(ms)) {
        if (trace) {
            trace('settle', { by: 'invalid', ok: false })
        }
        throw new Error(`invalid ms[${ms}]`)
    }

    //夾至計時器上限, 見_const.mjs
    ms = Math.min(ms, cst.TIMER_TIME_MAX)

    //label
    let label = get(opt, 'label', null)
    if (!isestr(label)) {
        label = ''
    }

    //msgErr, label為空時不加前綴, 避免訊息出現多餘之前導空白
    let msgErr = `timeout[${ms}ms]`
    if (isestr(label)) {
        msgErr = `${label} ${msgErr}`
    }

    //timeout, 計時器用Promise, 逾時即reject
    let t
    let timeout = genPm()
    let byTimeout = false //僅trace時用, 逾時計時器是否已到期
    let pmOk = null //僅trace時用, pm自身是否resolve
    if (trace) {
        pm.then(() => {
            pmOk = true
        }, () => {
            pmOk = false
        })
    }
    t = setTimeout(() => {
        if (trace) {
            byTimeout = true
            trace('timeout', { ms })
        }
        let e = new Error(msgErr)
        e.code = 'TIMEOUT' //供呼叫端辨別為逾時而非pm自身之錯誤
        e.timeout = ms
        timeout.reject(e)
    }, ms)
    if (trace) {
        trace('start', { ms })
    }

    //race, 先完成者勝出
    //無論勝負皆須clearTimeout: pm先完成時若不清除, 計時器會持續佔住event loop而延後process結束
    //pm逾時落敗後其自身之reject仍由Promise.race內部掛載之handler接住, 不會產生unhandled rejection
    let r = Promise.race([pm, timeout])
        .finally(() => {
            clearTimeout(t)
            if (trace) {
                trace('settle', { by: byTimeout ? 'timeout' : 'pm', ok: byTimeout ? false : pmOk === true })
            }
        })

    return r
}


export default pmTimeout