@hyzyn/dsh-tty 0.30.0 → 0.32.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +43 -14
- package/README.md +37 -10
- package/client.js +145 -45
- package/lib/index.d.ts +61 -7
- package/lib/index.js +109 -13
- package/lib/index.js.map +1 -1
- package/lib/shell-integration.d.ts +10 -0
- package/lib/shell-integration.js +148 -7
- package/lib/shell-integration.js.map +1 -1
- package/package.json +1 -1
- package/scripts/preview/harness.js +140 -7
- package/scripts/preview/mock-host.js +19 -0
- package/scripts/preview.mjs +11 -1
- package/scripts/pty-kill-probe.cjs +123 -0
- package/scripts/pty-kill-repro-plugin.mjs +72 -0
- package/scripts/pty-kill-repro.mjs +101 -0
- package/scripts/windows-smoke.mjs +97 -2
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* D100 的最小复现(**插件层**):把 W1–W5 的形状(经插件 + WS 的 spawn → 输入回显 → kill →
|
|
3
|
+
* 关连接)在一只进程里循环 N 次,**不带** W6/W7(agent 工具与只读保留)与 W8(第二个 app + PowerShell)。
|
|
4
|
+
*
|
|
5
|
+
* 它回答的是「触发点在插件这一层,还是要更后面的档」:D100 定位时用它在**没有任何 W8 代码**的
|
|
6
|
+
* 树上崩了 2/168 个会话(≈1.2%/会话),从而把「与 W8 相关」这条排除掉——W8 只是**加多了掷骰子
|
|
7
|
+
* 的次数**(每多一条会话就多一次 kill),不是成因。
|
|
8
|
+
*
|
|
9
|
+
* 跑法(真机 / Windows VM,用 `lib/` 的构建产物):
|
|
10
|
+
* node -r ./scripts/pty-kill-probe.cjs ./scripts/pty-kill-repro-plugin.mjs 150
|
|
11
|
+
* 装上 D100 闸门之后:150 个会话一轮跑满、探针里 150 次 `NATIVE-KILL`、**0 次双重 kill**。
|
|
12
|
+
*
|
|
13
|
+
* 进度用 `writeSync` 直写 fd(原生崩溃会丢掉异步写队列里的行,见 D62)。
|
|
14
|
+
*/
|
|
15
|
+
import { writeSync } from 'node:fs'
|
|
16
|
+
import { Context } from '@deepseek-ai/cordis'
|
|
17
|
+
import WebServerRuntime from '@deepseek-ai/dsh-host-webserver'
|
|
18
|
+
import { LocalSubprocessRuntime } from '@deepseek-ai/dsh-subprocess-local'
|
|
19
|
+
import WebSocket from 'ws'
|
|
20
|
+
import { name, inject, apply } from '../lib/index.js'
|
|
21
|
+
|
|
22
|
+
const loops = Number(process.argv[2] ?? 12)
|
|
23
|
+
const say = (s) => { try { writeSync(1, s + '\n') } catch { /* fd 没了 */ } }
|
|
24
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
25
|
+
|
|
26
|
+
// 同 pty-kill-repro.mjs:只在 Windows 上有意义(默认 shell 是 %COMSPEC%,打的是 ConPTY 的终止路径)。
|
|
27
|
+
if (process.platform !== 'win32') {
|
|
28
|
+
say(`# 这份复现只在 Windows 上有意义(当前 ${process.platform}):它打的是 ConPTY 的原生终止路径`)
|
|
29
|
+
process.exit(0)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const app = new Context()
|
|
33
|
+
app.plugin(WebServerRuntime, { host: '127.0.0.1', port: 0 })
|
|
34
|
+
app.plugin(LocalSubprocessRuntime)
|
|
35
|
+
app.plugin({
|
|
36
|
+
name: 'repro-stub',
|
|
37
|
+
apply: (ctx) => {
|
|
38
|
+
ctx.provide('settings', { describe: () => [], update: async () => {}, configure: () => () => {} })
|
|
39
|
+
ctx.provide('tools', { register: () => () => {} })
|
|
40
|
+
},
|
|
41
|
+
})
|
|
42
|
+
await app.plugin({ name, inject, apply }, { maxSessions: 4, term: 'xterm-256color', colorTerm: 'truecolor' }).await?.()
|
|
43
|
+
await sleep(400)
|
|
44
|
+
const port = app.webServer.port
|
|
45
|
+
say(`# loops=${loops} port=${port}`)
|
|
46
|
+
|
|
47
|
+
for (let i = 1; i <= loops; i += 1) {
|
|
48
|
+
say(`[${i}] connect`)
|
|
49
|
+
const ws = new WebSocket(`ws://127.0.0.1:${port}/api/dsh-tty/ws`)
|
|
50
|
+
let ready = false
|
|
51
|
+
let exited = false
|
|
52
|
+
ws.on('message', (raw) => {
|
|
53
|
+
const m = JSON.parse(raw.toString())
|
|
54
|
+
if (m.t === 'ready') ready = true
|
|
55
|
+
if (m.t === 'exit') exited = true
|
|
56
|
+
})
|
|
57
|
+
await new Promise((r) => ws.once('open', r))
|
|
58
|
+
ws.send(JSON.stringify({ t: 'spawn', cols: 100, rows: 30 }))
|
|
59
|
+
let t0 = Date.now()
|
|
60
|
+
while (!ready && Date.now() - t0 < 20000) await sleep(50)
|
|
61
|
+
say(`[${i}] ready=${String(ready)}`)
|
|
62
|
+
ws.send(JSON.stringify({ t: 'input', d: 'echo x\r\n' }))
|
|
63
|
+
await sleep(200)
|
|
64
|
+
ws.send(JSON.stringify({ t: 'kill' }))
|
|
65
|
+
t0 = Date.now()
|
|
66
|
+
while (!exited && Date.now() - t0 < 15000) await sleep(50)
|
|
67
|
+
say(`[${i}] exited=${String(exited)}`)
|
|
68
|
+
try { ws.close() } catch { /* 已关 */ }
|
|
69
|
+
await sleep(150)
|
|
70
|
+
}
|
|
71
|
+
say(`# 全部 ${loops} 轮跑完(没崩)`)
|
|
72
|
+
process.exit(0)
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* D100 的最小复现(**裸层**):只做 `spawnTerminal` + 一层或多层终止,不跑 smoke 其余部分。
|
|
3
|
+
*
|
|
4
|
+
* 用途有两个:
|
|
5
|
+
* 1. 隔离变量——「单次 kill 无害、双重 kill 致命」这句话是拿它跑出来的 A/B(各 150 轮):
|
|
6
|
+
* 无闸门 `node -r ./scripts/pty-kill-probe.cjs ./scripts/pty-kill-repro.mjs kill+terminate 150`
|
|
7
|
+
* 一次性闸门 `PTY_PROBE_GATE=1 node -r ./scripts/pty-kill-probe.cjs ./scripts/pty-kill-repro.mjs kill+terminate 150`
|
|
8
|
+
* 单次对照 `node -r ./scripts/pty-kill-probe.cjs ./scripts/pty-kill-repro.mjs terminate 150`
|
|
9
|
+
* 真机(Win11 ARM64)实测:无闸门 188 次原生 kill / 94 次双重 → 第 75 轮崩
|
|
10
|
+
* (退出码 -1073740940 = `0xC0000374`);装了闸门 150/150 每 pty 恰好 1 次、跑满不崩。
|
|
11
|
+
* 2. 复现**插件实际用的那条顺序**:`kill+terminate` 才是本包 `killSessionNow` 的顺序
|
|
12
|
+
* (先 D48 的预杀、后 DSH 的 `terminate()`);反过来(`terminate+kill`)也留着做对照。
|
|
13
|
+
*
|
|
14
|
+
* `kill+terminate` 为什么会双重(D100 的正文):DSH 的 `stopShellWindows()` 在
|
|
15
|
+
* `rootIdentity === undefined` 时**也**调 `terminal.kill()`,而 Windows 上 rootIdentity 恒为
|
|
16
|
+
* undefined(`pid` 要等 conout 连接才有值,DSH 构造 handler 时取到 0)⇒ 两个调用方叠加。
|
|
17
|
+
* 想看这条证据就跑上面的第一行:探针会打出两行 `NATIVE-KILL id=N`。
|
|
18
|
+
*
|
|
19
|
+
* 进度用 `writeSync` 直写 fd(原生崩溃会丢掉异步写队列里的行,见 D62)。
|
|
20
|
+
*
|
|
21
|
+
* 用法(在包根):node ./scripts/pty-kill-repro.mjs <mode> <iterations>
|
|
22
|
+
* mode: spawn-only | terminate | nodepty-kill | terminate+kill | kill+terminate
|
|
23
|
+
* | kill+kill | terminate+terminate | write+<上面任意终止组合>
|
|
24
|
+
* 例:write+kill+terminate(最接近插件真实路径:先写输入,再预杀,再 terminate)
|
|
25
|
+
*/
|
|
26
|
+
import { writeSync } from 'node:fs'
|
|
27
|
+
import { Context } from '@deepseek-ai/cordis'
|
|
28
|
+
import { LocalSubprocessRuntime } from '@deepseek-ai/dsh-subprocess-local'
|
|
29
|
+
|
|
30
|
+
const mode = process.argv[2] ?? 'kill+terminate'
|
|
31
|
+
const iterations = Number(process.argv[3] ?? 15)
|
|
32
|
+
const say = (s) => { try { writeSync(1, s + '\n') } catch { /* fd 没了 */ } }
|
|
33
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
34
|
+
|
|
35
|
+
// 这份复现针对的是 Windows 的 ConPTY 终止路径:非 win32 上既没有 `cmd.exe` 也没有那条原生
|
|
36
|
+
// `PtyKill`,跑起来只会得到「命令不存在」——直接说清楚,别让人以为复现失败。
|
|
37
|
+
if (process.platform !== 'win32') {
|
|
38
|
+
say(`# 这份复现只在 Windows 上有意义(当前 ${process.platform}):它打的是 ConPTY 的原生终止路径`)
|
|
39
|
+
process.exit(0)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const ALLOWED = new Set([
|
|
43
|
+
'spawn-only', 'terminate', 'nodepty-kill', 'terminate+kill', 'kill+terminate',
|
|
44
|
+
'kill+kill', 'terminate+terminate',
|
|
45
|
+
'write+kill', 'write+terminate', 'write+kill+terminate', 'write+terminate+kill',
|
|
46
|
+
])
|
|
47
|
+
if (!ALLOWED.has(mode)) {
|
|
48
|
+
say(`# 未知 mode=${mode};可选:${[...ALLOWED].join(' | ')}`)
|
|
49
|
+
process.exit(2)
|
|
50
|
+
}
|
|
51
|
+
const steps = mode.split('+')
|
|
52
|
+
const doWrite = steps[0] === 'write'
|
|
53
|
+
const kills = doWrite ? steps.slice(1) : steps
|
|
54
|
+
|
|
55
|
+
say(`# mode=${mode} iterations=${iterations} node=${process.version} kills=[${kills.join(',')}]`)
|
|
56
|
+
const app = new Context()
|
|
57
|
+
const fiber = app.plugin(LocalSubprocessRuntime)
|
|
58
|
+
await fiber.await()
|
|
59
|
+
const subprocess = app.get('subprocess')
|
|
60
|
+
if (subprocess === undefined) { say('# subprocess 服务缺失!'); process.exit(2) }
|
|
61
|
+
|
|
62
|
+
/** 走一层终止:`terminate` = DSH 的 handle.terminate()(closeOnce);其余 = node-pty 的 kill。 */
|
|
63
|
+
const callKill = (handle, kind) => {
|
|
64
|
+
if (kind === 'terminate') {
|
|
65
|
+
return handle.terminate().then(
|
|
66
|
+
() => { say(' terminate() 返回') },
|
|
67
|
+
(e) => { say(` terminate() 抛: ${e?.message ?? e}`) },
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
const kill = handle.terminal?.kill
|
|
71
|
+
if (typeof kill !== 'function') { say(' terminal.kill 不存在'); return Promise.resolve() }
|
|
72
|
+
try {
|
|
73
|
+
kill.call(handle.terminal)
|
|
74
|
+
say(' kill() 返回')
|
|
75
|
+
} catch (e) {
|
|
76
|
+
say(` kill() 抛: ${e?.message ?? e}`)
|
|
77
|
+
}
|
|
78
|
+
return Promise.resolve()
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
for (let i = 1; i <= iterations; i += 1) {
|
|
82
|
+
say(`[${i}] before-spawn`)
|
|
83
|
+
const handle = await subprocess.spawnTerminal({
|
|
84
|
+
argv: ['cmd.exe'], rows: 30, cols: 100, cwd: process.cwd(), env: { ...process.env }, graceMs: 5000,
|
|
85
|
+
})
|
|
86
|
+
// Windows 上当轮拿到的 pid 就是 0(D100 的另一半证据:node-pty 的 pid 要等 conout 连接才有值)
|
|
87
|
+
say(`[${i}] spawned pid=${handle.pid} (${new Date().toISOString()})`)
|
|
88
|
+
if (doWrite) { await handle.write('echo x\r\n'); await sleep(150); say(`[${i}] wrote`) }
|
|
89
|
+
for (const kind of kills) {
|
|
90
|
+
say(`[${i}] -> ${kind}`)
|
|
91
|
+
await callKill(handle, kind)
|
|
92
|
+
await sleep(kind === 'terminate' ? 0 : 50)
|
|
93
|
+
}
|
|
94
|
+
await Promise.race([
|
|
95
|
+
handle.done.then((d) => say(`[${i}] done ${JSON.stringify(d)}`)),
|
|
96
|
+
sleep(4000).then(() => say(`[${i}] done 超时(不等待)`)),
|
|
97
|
+
]).catch(() => {})
|
|
98
|
+
await sleep(60)
|
|
99
|
+
}
|
|
100
|
+
say(`# 全部 ${iterations} 轮跑完(没崩) mode=${mode}`)
|
|
101
|
+
process.exit(0)
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
* 用法:pnpm --filter @hyzyn/dsh-tty windows-smoke
|
|
16
16
|
* 退出码:0 = 全部 PASS 或跳过,1 = 任一 FAIL。
|
|
17
17
|
*/
|
|
18
|
+
import { existsSync } from 'node:fs'
|
|
18
19
|
import { Context } from '@deepseek-ai/cordis'
|
|
19
20
|
import WebServerRuntime from '@deepseek-ai/dsh-host-webserver'
|
|
20
21
|
import { LocalSubprocessRuntime } from '@deepseek-ai/dsh-subprocess-local'
|
|
@@ -85,6 +86,8 @@ process.on('exit', (code) => {
|
|
|
85
86
|
const RESULTS = []
|
|
86
87
|
function pass(label) { RESULTS.push(['PASS', label]); console.log(' ✔ PASS ' + label) }
|
|
87
88
|
function fail(label, detail) { RESULTS.push(['FAIL', label, detail]); console.error(' ✘ FAIL ' + label + (detail ? ' — ' + detail : '')) }
|
|
89
|
+
/** SKIP 单列一档:**不许混进 PASS 计数**,否则「跳过」会伪装成 12/12(W8 在本机没有 PowerShell 时走这条)。 */
|
|
90
|
+
function skip(label) { RESULTS.push(['SKIP', label]); console.log(' ⏭ SKIP ' + label) }
|
|
88
91
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
89
92
|
|
|
90
93
|
/** 简易 ws 会话客户端(与 integration.mjs 同形,只保留本脚本要用的部分)。 */
|
|
@@ -142,6 +145,22 @@ const ENTER = '\r\n'
|
|
|
142
145
|
*/
|
|
143
146
|
const TOOLS = new Map()
|
|
144
147
|
|
|
148
|
+
/** 本机可用的 PowerShell(优先 PowerShell 7,再 Windows PowerShell 5.1);都没有则 null(W8 跳过)。 */
|
|
149
|
+
function resolvePowerShell() {
|
|
150
|
+
const candidates = [
|
|
151
|
+
'C:/Program Files/PowerShell/7/pwsh.exe',
|
|
152
|
+
'C:/Windows/System32/WindowsPowerShell/v1.0/powershell.exe',
|
|
153
|
+
]
|
|
154
|
+
for (const candidate of candidates) {
|
|
155
|
+
try {
|
|
156
|
+
if (existsSync(candidate)) return candidate
|
|
157
|
+
} catch {
|
|
158
|
+
/* 权限等异常一律当「没有」 */
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return null
|
|
162
|
+
}
|
|
163
|
+
|
|
145
164
|
async function run() {
|
|
146
165
|
const app = new Context()
|
|
147
166
|
const wsFiber = app.plugin(WebServerRuntime, { host: '127.0.0.1', port: 0 })
|
|
@@ -341,6 +360,77 @@ async function run() {
|
|
|
341
360
|
}
|
|
342
361
|
}
|
|
343
362
|
tracer.leave('W7 退出后的只读保留')
|
|
363
|
+
|
|
364
|
+
// W8:PowerShell 家族的 shell 集成(OSC 133/7)必须真的从 ConPTY 里穿出来
|
|
365
|
+
//
|
|
366
|
+
// 为什么在这一档而不只在单测里:单测只能断言「spawn 计划长什么样」,而这条链路真正
|
|
367
|
+
// 容易断的地方在 ConPTY —— 标记是 conhost **直通转发**的旁路(渲染路径之外),被吞掉
|
|
368
|
+
// 或晚到只有真机才看得出。另起一个 app 实例是为了改 shell 配置:W1–W7 必须保持真实的
|
|
369
|
+
// `%COMSPEC%` 链路(它们钉的正是「没有 POSIX 包装层、跑的就是 cmd.exe」)。
|
|
370
|
+
//
|
|
371
|
+
// **不再按 CI 跳过**(2026-10-13):2026-10-12 那版「CI 上默认跳过 W8」是**绕开**当时未定性的
|
|
372
|
+
// `0xC0000374`,而根因已经定位并修好(同一个 PTY 被两个调用方各原生 kill 一次 → 见
|
|
373
|
+
// packages/tty/DEFECTS.md 的 D100 / ROADMAP 的 2026-10-13 一节)。装上闸门后真机(Win11 ARM64)
|
|
374
|
+
// 强制带 W8 连跑 6 轮全部 `12/12 PASS`。**动这里之前先读那条**:它记着当时的崩点分布与 A/B。
|
|
375
|
+
console.log('\n[W8] PowerShell + shell 集成(OSC 133/7)')
|
|
376
|
+
tracer.enter('W8 PowerShell 集成')
|
|
377
|
+
try {
|
|
378
|
+
const shellPath = resolvePowerShell()
|
|
379
|
+
if (shellPath === null) {
|
|
380
|
+
// 本机没有 PowerShell:这是**真跳过**(不是「按设计绕过」),如实进 SKIP 一档——
|
|
381
|
+
// 分母扣掉它、汇总行点名,不许装成 12/12。
|
|
382
|
+
skip('W8 PowerShell shell 集成(本机没找到 pwsh.exe / powershell.exe)')
|
|
383
|
+
} else {
|
|
384
|
+
const app2 = new Context()
|
|
385
|
+
const ws2Fiber = app2.plugin(WebServerRuntime, { host: '127.0.0.1', port: 0 })
|
|
386
|
+
const sub2Fiber = app2.plugin(LocalSubprocessRuntime)
|
|
387
|
+
const stub2Fiber = app2.plugin({
|
|
388
|
+
name: 'windows-smoke-stub-2',
|
|
389
|
+
apply: (ctx) => {
|
|
390
|
+
ctx.provide('settings', { describe: () => [], update: async () => {}, configure: () => () => {} })
|
|
391
|
+
ctx.provide('tools', { register: () => () => {} })
|
|
392
|
+
},
|
|
393
|
+
})
|
|
394
|
+
await stub2Fiber.await()
|
|
395
|
+
const plugin2Fiber = app2.plugin({ name, inject, apply }, { shell: shellPath, term: 'xterm-256color', colorTerm: 'truecolor' })
|
|
396
|
+
await ws2Fiber.await()
|
|
397
|
+
await sub2Fiber.await()
|
|
398
|
+
await plugin2Fiber.await()
|
|
399
|
+
await sleep(400)
|
|
400
|
+
|
|
401
|
+
const s3 = openSession(app2.webServer.port)
|
|
402
|
+
await s3.open()
|
|
403
|
+
s3.client.send(JSON.stringify({ t: 'spawn', cols: 100, rows: 30 }))
|
|
404
|
+
await s3.waitFor(() => s3.state.ready !== null, 25000, 'PowerShell ready')
|
|
405
|
+
// 桩装好 prompt 就发首个 A 标记:到了 = `-File` 桩真的被载入并生效
|
|
406
|
+
await s3.waitFor(() => /\]133;A/.test(s3.state.text), 20000, 'PowerShell 首个 A 标记')
|
|
407
|
+
pass('W8a PowerShell 桩生效(ConPTY 里收到 OSC 133;A)')
|
|
408
|
+
|
|
409
|
+
// `$env:OS` 由 PowerShell 展开(回显里只有字面量),与 W2/W3 同一条判据
|
|
410
|
+
s3.client.send(JSON.stringify({ t: 'input', d: `echo IT_PS_$env:OS${ENTER}` }))
|
|
411
|
+
await s3.waitFor(
|
|
412
|
+
() => /IT_PS_Windows_NT/.test(s3.state.text) && /\]133;D;0/.test(s3.state.text),
|
|
413
|
+
25000,
|
|
414
|
+
'PowerShell 的 B/D 与命令输出',
|
|
415
|
+
)
|
|
416
|
+
pass('W8b PowerShell 发全 B/D(退出码 0),且命令真的被执行')
|
|
417
|
+
|
|
418
|
+
s3.client.send(JSON.stringify({ t: 'kill' }))
|
|
419
|
+
try {
|
|
420
|
+
await s3.waitFor(() => s3.state.exited !== null, 15000, 'PowerShell exit')
|
|
421
|
+
} catch {
|
|
422
|
+
/* 没收到 exit 也不影响上面两条结论 */
|
|
423
|
+
}
|
|
424
|
+
try {
|
|
425
|
+
s3.client.close()
|
|
426
|
+
} catch {
|
|
427
|
+
/* 已关闭 */
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
} catch (error) {
|
|
431
|
+
fail('W8 PowerShell shell 集成', error.message)
|
|
432
|
+
}
|
|
433
|
+
tracer.leave('W8 PowerShell 集成')
|
|
344
434
|
}
|
|
345
435
|
|
|
346
436
|
await run()
|
|
@@ -348,9 +438,14 @@ await run()
|
|
|
348
438
|
// **异常终止**用的,断言失败被说成「崩溃」只会误导。
|
|
349
439
|
exitingNormally = true
|
|
350
440
|
const failed = RESULTS.filter((row) => row[0] === 'FAIL')
|
|
351
|
-
|
|
441
|
+
const skipped = RESULTS.filter((row) => row[0] === 'SKIP')
|
|
442
|
+
/** 分母扣掉 SKIP:**跳过的档不许算进 PASS 数**(否则「跳过」看起来像全过,正是要防的假绿)。 */
|
|
443
|
+
const counted = RESULTS.length - skipped.length
|
|
444
|
+
const summary = String(counted - failed.length) + '/' + String(counted) + ' PASS' +
|
|
445
|
+
(skipped.length > 0 ? '(另 ' + String(skipped.length) + ' 项 SKIP:' + skipped.map((row) => row[1]).join(';') + ')' : '')
|
|
446
|
+
console.log('\n==== Windows 冒烟:' + summary + ' ====')
|
|
352
447
|
// 同步再打一份结论:万一下面的 flush 屏障之前进程就没了(原生崩溃),至少这一行一定在。
|
|
353
|
-
traceSync(`Windows 冒烟结论:${
|
|
448
|
+
traceSync(`Windows 冒烟结论:${summary}`)
|
|
354
449
|
for (const row of failed) console.error(' ✘ ' + row[1] + (row[2] ? ' — ' + row[2] : ''))
|
|
355
450
|
clearTimeout(watchdog)
|
|
356
451
|
/**
|