dsh-pwsh-plugin 0.2.17 → 0.2.19

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.
@@ -1,42 +1,56 @@
1
- /**
2
- * pwsh-run 会话客户端 —— 常驻 pwsh 进程的生命周期与协议编解码
3
- *
4
- * 本模块**不依赖 DSH**,只要求调用方给一个最小的 subprocess 接口:
5
- * interface SubprocessLike {
6
- * spawn(spec): { stdin, stdout, stderr, done, terminate(), waitForExit(signal?) }
7
- * }
8
- * 这样既能挂在 ctx.subprocess 上,也能在纯 node 里做集成测试。
9
- */
1
+ /** 常驻 pwsh 的协议、串行调用及有界输出收集。 */
2
+ import { gzipSync } from 'node:zlib'
3
+ import { StringDecoder } from 'node:string_decoder'
10
4
 
11
- const DEFAULT_GRACE_MS = 3000
12
-
13
- /**
14
- * 单次调用的输出收集上限(每个流各自计)。
15
- * 常驻会话没有原生工具那种 collect 尾巴截断,几个 MB 的输出也照单全收——不设上限的话
16
- * 一段死循环打印就能把宿主进程的内存吃光。超限只丢弃后续内容并打标记,不打断脚本。
17
- */
18
5
  export const DEFAULT_MAX_OUTPUT_BYTES = 8 * 1024 * 1024
19
-
20
- /** 把脚本正文编码成协议负载。 */
6
+ export const DIAGNOSTIC_BYTES = 64 * 1024
7
+ export const RESPONSE_GRACE_MS = 5000
8
+ // Node 定时器采用有符号 32 位毫秒,执行预算还需给收尾留下余量。
9
+ export const MAX_TIMEOUT_MS = 2147483647 - RESPONSE_GRACE_MS
10
+ const MAX_FRAME_CHARS = 32768
21
11
  export const encodeScript = (text) => Buffer.from(text, 'utf8').toString('base64')
22
12
 
23
- /** 由 UTF-16LE 的 base64 生成 pwsh 启动参数(-EncodedCommand,避开 32K 命令行里的转义问题)。 */
13
+ export function validateTimeout(value) {
14
+ if (!Number.isSafeInteger(value) || value < 1 || value > MAX_TIMEOUT_MS) {
15
+ throw new Error('pwsh_run timeoutMs must be an integer between 1 and ' + MAX_TIMEOUT_MS)
16
+ }
17
+ return value
18
+ }
19
+
24
20
  export function bootArgv(serverSource) {
25
- return ['-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(serverSource, 'utf16le').toString('base64')]
21
+ // 压缩启动源码:服务端功能增长不能再撞 Windows 的 32K 命令行上限。
22
+ const payload = gzipSync(Buffer.from(serverSource, 'utf8')).toString('base64')
23
+ const boot = "$m=[IO.MemoryStream]::new([Convert]::FromBase64String('" + payload + "'));"
24
+ + "$g=[IO.Compression.GZipStream]::new($m,[IO.Compression.CompressionMode]::Decompress);"
25
+ + "$r=[IO.StreamReader]::new($g,[Text.Encoding]::UTF8);"
26
+ + "try { & ([ScriptBlock]::Create($r.ReadToEnd())) } finally { $r.Dispose(); $m.Dispose() }"
27
+ const encoded = Buffer.from(boot, 'utf16le').toString('base64')
28
+ if (encoded.length > 30000) throw new Error('pwsh_run compressed bootstrap exceeds the Windows command-line budget')
29
+ return ['-NoProfile', '-NonInteractive', '-EncodedCommand', encoded]
30
+ }
31
+
32
+ const cancelled = () => new Error('pwsh_run cancelled before execution')
33
+ export function abortable(promise, signal, shouldAbort = () => true) {
34
+ if (signal === undefined) return promise
35
+ if (signal.aborted) return Promise.reject(cancelled())
36
+ return new Promise((resolve, reject) => {
37
+ const abort = () => { if (shouldAbort()) reject(cancelled()) }
38
+ signal.addEventListener('abort', abort, { once: true })
39
+ promise.then(resolve, reject).finally(() => signal.removeEventListener('abort', abort))
40
+ })
41
+ }
42
+
43
+ function clipUtf8(text, limit) {
44
+ const bytes = Buffer.from(text, 'utf8')
45
+ if (bytes.length <= limit) return text
46
+ return new StringDecoder('utf8').write(bytes.subarray(0, Math.max(0, limit)))
26
47
  }
27
48
 
28
- /**
29
- * 一个常驻 pwsh 会话。
30
- *
31
- * 线程模型:服务端是单 runspace,调用必须串行;run() 内部用 Promise 链排队。
32
- * 生命周期:进程退出 / stdout 出错 / 服务端发 BYE 都算「会话死亡」,
33
- * 在途调用立刻失败,下一次 acquire 会重建。
34
- */
35
49
  export class PwshSession {
36
- constructor(handle, label, maxOutputBytes) {
50
+ constructor(handle, label, maxOutputBytes = DEFAULT_MAX_OUTPUT_BYTES) {
37
51
  this.handle = handle
38
52
  this.label = label
39
- this.maxOutputBytes = maxOutputBytes === undefined ? DEFAULT_MAX_OUTPUT_BYTES : maxOutputBytes
53
+ this.maxOutputBytes = maxOutputBytes
40
54
  this.buffer = ''
41
55
  this.pending = new Map()
42
56
  this.activeId = null
@@ -46,178 +60,172 @@ export class PwshSession {
46
60
  this.queue = Promise.resolve()
47
61
  this.lastUsed = Date.now()
48
62
  this.stderrTail = ''
49
-
50
- const stdout = handle.stdout
51
- if (stdout !== undefined && stdout !== null) {
52
- if (typeof stdout.setEncoding === 'function') stdout.setEncoding('utf8')
53
- stdout.on('data', (chunk) => this.#onData(chunk))
54
- stdout.on('error', (error) => this.#fail('stdout error: ' + String(error)))
55
- }
56
- const stderr = handle.stderr
57
- if (stderr !== undefined && stderr !== null) {
58
- if (typeof stderr.setEncoding === 'function') stderr.setEncoding('utf8')
59
- stderr.on('data', (chunk) => { this.stderrTail = (this.stderrTail + chunk).slice(-2000) })
60
- }
61
- if (handle.done !== undefined) {
62
- handle.done.then(
63
- (outcome) => this.#fail('session died code=' + String(outcome === undefined ? 'null' : outcome.exitCode)),
64
- (error) => this.#fail('session died: ' + String(error)),
65
- )
66
- }
63
+ this.startupWarnings = []
64
+ handle.stdin?.on?.('error', (error) => this.#fail('stdin error: ' + String(error)))
65
+ handle.stdout?.setEncoding?.('utf8')
66
+ handle.stdout?.on('data', (chunk) => this.#onData(chunk))
67
+ handle.stdout?.on('error', (error) => this.#fail('stdout error: ' + String(error)))
68
+ handle.stderr?.setEncoding?.('utf8')
69
+ handle.stderr?.on('data', (chunk) => { this.stderrTail = (this.stderrTail + chunk).slice(-2000) })
70
+ handle.stderr?.on('error', (error) => { this.stderrTail = (this.stderrTail + String(error)).slice(-2000) })
71
+ handle.done?.then(
72
+ (outcome) => this.#fail('session died code=' + String(outcome?.exitCode ?? 'null')),
73
+ (error) => this.#fail('session died: ' + String(error)),
74
+ )
67
75
  }
68
76
 
69
- /** 追加收到的字节,按行切出协议消息。 */
70
77
  #onData(chunk) {
71
78
  this.buffer += chunk
72
79
  for (;;) {
73
80
  const index = this.buffer.indexOf('\n')
74
- if (index < 0) return
81
+ if (index < 0) {
82
+ if (this.buffer.length > MAX_FRAME_CHARS) {
83
+ this.#fail('pwsh protocol frame exceeded its size limit')
84
+ void this.dispose()
85
+ }
86
+ return
87
+ }
75
88
  const line = this.buffer.slice(0, index).replace(/\r$/, '')
76
89
  this.buffer = this.buffer.slice(index + 1)
90
+ if (line.length > MAX_FRAME_CHARS) {
91
+ this.#fail('pwsh protocol frame exceeded its size limit')
92
+ void this.dispose()
93
+ return
94
+ }
77
95
  this.#onLine(line)
78
96
  }
79
97
  }
80
98
 
81
99
  #onLine(line) {
82
- if (line.length === 0) return
83
- if (line === '<<<BYE>>>') {
84
- this.#fail('session said BYE')
85
- return
86
- }
100
+ if (line === '<<<BYE>>>') { this.#fail('session said BYE'); return }
87
101
  const marker = line.match(/^<<<([BTE]):([^>:]+)(?::([01]))?>>>$/)
88
102
  if (marker !== null) {
89
- const kind = marker[1]
90
- const id = marker[2]
91
- if (kind === 'B') {
92
- this.activeId = id
93
- return
94
- }
103
+ const [, kind, id] = marker
104
+ if (kind === 'B') { this.activeId = id; return }
95
105
  const entry = this.pending.get(id)
96
106
  if (entry === undefined) return
97
- if (kind === 'T') {
98
- if (marker[3] === '1') entry.response.timedOut = true
99
- return
100
- }
101
- // E:本次响应收齐
107
+ if (kind === 'T') { entry.response.timedOut = marker[3] === '1'; return }
102
108
  this.activeId = null
103
109
  this.pending.delete(id)
104
110
  entry.resolve(entry.response)
105
111
  return
106
112
  }
107
- const part = line.match(/^([OEWVXFS]):(.*)$/)
108
- if (part === null) return
109
- const entry = this.activeId === null ? undefined : this.pending.get(this.activeId)
113
+ const entry = this.pending.get(this.activeId)
110
114
  if (entry === undefined) return
111
- const text = Buffer.from(part[2], 'base64').toString('utf8')
112
- // 哨兵必须无条件收:它决定「跑完 / 抛异常 / 被中断」,丢了会让上层误判。
113
- if (part[1] === 'S') {
114
- entry.response.sentinel = text
115
- return
116
- }
117
- if (entry.response.truncated) return
118
- entry.response.bytes += text.length
119
- if (entry.response.bytes > this.maxOutputBytes) {
120
- // 只标记一次;后续数据行直接丢弃,脚本本身不受影响。
121
- entry.response.truncated = true
122
- return
115
+ if (line === 'L:') { entry.response.truncated = true; return }
116
+ const part = line.match(/^([OEWVXFSID])(\+?):(.*)$/)
117
+ if (part === null) return
118
+ let text = Buffer.from(part[3], 'base64').toString('utf8')
119
+ const kind = part[1]
120
+ if (kind === 'S') { entry.response.sentinel = text; return }
121
+ const diagnostic = 'EXF'.includes(kind)
122
+ const counter = diagnostic ? 'diagnosticBytes' : 'bytes'
123
+ const records = diagnostic ? 'diagnosticRecords' : 'records'
124
+ const newRecord = entry.continuing !== kind
125
+ const separator = newRecord && entry.response[records] > 0 ? 1 : 0
126
+ const remaining = (diagnostic ? DIAGNOSTIC_BYTES : this.maxOutputBytes) - entry.response[counter] - separator
127
+ if (remaining < 0) { entry.response.truncated = true; return }
128
+ const kept = clipUtf8(text, remaining)
129
+ if (kept !== text) entry.response.truncated = true
130
+ text = kept
131
+ if (text.length > 0 || part[3].length === 0) {
132
+ entry.response[counter] += Buffer.byteLength(text, 'utf8') + separator
133
+ if (newRecord) entry.response[records]++
123
134
  }
124
- switch (part[1]) {
125
- case 'O': entry.response.out.push(text); break
126
- case 'E': entry.response.err.push(text); break
127
- case 'W': entry.response.warn.push(text); break
128
- case 'V': entry.response.verbose.push(text); break
129
- case 'X': entry.response.exceptions.push(text); break
130
- case 'F': entry.response.fatal.push(text); break
131
- default: break
135
+ const field = { O: 'out', E: 'err', W: 'warn', V: 'verbose', X: 'exceptions', F: 'fatal', I: 'information', D: 'debug' }[kind]
136
+ const items = entry.response[field]
137
+ if (text.length > 0 || part[3].length === 0) {
138
+ if (entry.continuing === kind && items.length > 0) items[items.length - 1] += text
139
+ else items.push(text)
132
140
  }
141
+ entry.continuing = part[2] === '+' ? kind : null
133
142
  }
134
143
 
135
- /** 会话不可再用:在途调用立刻失败。 */
136
144
  #fail(reason) {
137
- if (this.dead) return
138
- this.dead = true
139
- this.deathReason = reason
140
- for (const entry of this.pending.values()) entry.reject(new Error(reason))
145
+ if (!this.dead) { this.dead = true; this.deathReason = reason }
146
+ for (const entry of this.pending.values()) entry.reject(new Error(this.deathReason ?? reason))
141
147
  this.pending.clear()
142
148
  this.activeId = null
143
149
  }
144
150
 
145
- /** 杀掉进程并等待托管范围清空(Windows 上 Job Object 会连带回收子进程树)。 */
146
- async dispose() {
147
- this.dead = true
148
- try {
149
- this.handle.terminate()
150
- } catch {
151
- // terminate 幂等:进程已退出时忽略。
152
- }
153
- if (typeof this.handle.waitForExit === 'function') {
154
- try {
155
- await this.handle.waitForExit(AbortSignal.timeout(3000))
156
- } catch {
157
- // 托管范围无法观察时不再等待。
158
- }
159
- }
151
+ dispose(reason = 'pwsh session disposed') {
152
+ this.#fail(reason)
153
+ this.disposal ??= (async () => {
154
+ try { this.handle.terminate() } catch { /* 已退出的进程不需要再次终止。 */ }
155
+ const abort = new AbortController()
156
+ const timer = setTimeout(() => abort.abort(), 3000)
157
+ try { await this.handle.waitForExit?.(abort.signal) } catch { /* 等待范围已终止或不可观察。 */ }
158
+ finally { clearTimeout(timer) }
159
+ })()
160
+ return this.disposal
160
161
  }
161
162
 
162
- /** 跑一段脚本;同一会话内串行。 */
163
- run(script, timeoutMs) {
164
- const execute = () => this.#send(script, timeoutMs)
163
+ run(script, timeoutMs, options = {}) {
164
+ validateTimeout(timeoutMs)
165
+ if (options.signal?.aborted) return Promise.reject(cancelled())
166
+ let started = false
167
+ const execute = () => { started = true; return this.#send(script, timeoutMs, options) }
165
168
  const result = this.queue.then(execute, execute)
166
169
  this.queue = result.then(() => undefined, () => undefined)
167
- return result
170
+ return abortable(result, options.signal, () => !started)
168
171
  }
169
172
 
170
- #send(script, timeoutMs) {
171
- if (this.dead) return Promise.reject(new Error(this.deathReason === null ? 'session is dead' : this.deathReason))
172
- const stdin = this.handle.stdin
173
- if (stdin === undefined || stdin === null) return Promise.reject(new Error('session has no stdin pipe'))
173
+ #send(script, timeoutMs, options) {
174
+ if (options.signal?.aborted) return Promise.reject(cancelled())
175
+ if (this.dead) return Promise.reject(new Error(this.deathReason ?? 'session is dead'))
176
+ if (!this.handle.stdin) return Promise.reject(new Error('session has no stdin pipe'))
174
177
  const id = 'c' + String(++this.sequence)
175
178
  this.lastUsed = Date.now()
176
- const response = { out: [], err: [], warn: [], verbose: [], exceptions: [], fatal: [], sentinel: null, timedOut: false, bytes: 0, truncated: false }
177
- const seconds = Math.max(1, Math.ceil(timeoutMs / 1000))
179
+ const response = { out: [], err: [], warn: this.startupWarnings.splice(0), verbose: [], information: [], debug: [], exceptions: [], fatal: [], sentinel: null, timedOut: false, bytes: 0, diagnosticBytes: 0, records: 0, diagnosticRecords: 0, truncated: false }
178
180
  return new Promise((resolve, reject) => {
179
- this.pending.set(id, { response, resolve, reject })
180
- stdin.write(id + ' ' + String(seconds) + ' ' + encodeScript(script) + '\n', (error) => {
181
+ let timer
182
+ const finish = (fn, value) => {
183
+ clearTimeout(timer)
184
+ options.signal?.removeEventListener('abort', onAbort)
185
+ this.lastUsed = Date.now()
186
+ fn(value)
187
+ }
188
+ const stop = (isCancellation) => {
189
+ this.pending.delete(id)
190
+ response.cancelled = isCancellation
191
+ response.noResponse = !isCancellation
192
+ finish(resolve, response)
193
+ void this.dispose(isCancellation ? 'pwsh session cancelled' : 'pwsh session response timed out')
194
+ }
195
+ const onAbort = () => stop(true)
196
+ this.pending.set(id, { response, resolve: (v) => finish(resolve, v), reject: (e) => finish(reject, e), continuing: null })
197
+ // 只有实际发送后才开始计执行时间;排队取消不会杀前一条调用。
198
+ timer = setTimeout(() => stop(false), timeoutMs + RESPONSE_GRACE_MS)
199
+ options.signal?.addEventListener('abort', onAbort, { once: true })
200
+ this.handle.stdin.write(id + ' ' + timeoutMs + ' ' + encodeScript(JSON.stringify({ script, workdir: options.workdir })) + ' ' + this.maxOutputBytes + '\n', (error) => {
181
201
  if (error) {
202
+ const entry = this.pending.get(id)
182
203
  this.pending.delete(id)
183
- reject(new Error('write to pwsh session failed: ' + String(error)))
204
+ entry?.reject(new Error('write to pwsh session failed: ' + error))
184
205
  }
185
206
  })
186
207
  })
187
208
  }
188
209
  }
189
210
 
190
- /**
191
- * 启动一个会话并完成握手(跑一句 $PSVersionTable 确认真的活着)。
192
- * 服务端启动要解析脚本 + 打开 runspace,实测约 1s,所以握手超时给到 20s。
193
- */
194
211
  export async function startSession(subprocess, options) {
195
- // argv:调用方已经算好的完整命令行。沙箱给出的是一条完整受限命令(node runner.js … -- pwsh.exe …),
196
- // 拆成前缀再接会更脆,所以整条用;没有沙箱时由调用方给出裸 pwsh 命令行。
197
- const argv = Array.isArray(options.argv) ? options.argv : [options.executable].concat(bootArgv(options.serverSource))
198
- const handle = subprocess.spawn({
199
- argv,
200
- cwd: options.cwd,
201
- stdio: {
202
- stdin: 'pipe',
203
- // 必须自己解协议:collect 模式只保留尾部,会把前面的响应截掉。
204
- stdout: 'pipe',
205
- // 同理用 pipe 而非 collect:collect 模式下 handle.stderr 是 undefined,
206
- // 启动失败的诊断尾巴会永远读不到(实测踩过)。这里自己收 2000 字符尾巴。
207
- stderr: 'pipe',
208
- },
209
- graceMs: DEFAULT_GRACE_MS,
210
- })
211
- const session = new PwshSession(handle, options.label === undefined ? 'pwsh' : options.label, options.maxOutputBytes)
212
- const probe = await Promise.race([
213
- session.run('$PSVersionTable.PSVersion.Major', 15000),
214
- new Promise((resolve) => setTimeout(() => resolve(null), 20000)),
215
- ])
216
- if (probe === null || probe.sentinel !== 'ok') {
212
+ if (options.signal?.aborted) throw cancelled()
213
+ const argv = options.argv ?? [options.executable, ...bootArgv(options.serverSource)]
214
+ const handle = subprocess.spawn({ argv, cwd: options.cwd, stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'pipe' }, graceMs: 3000 })
215
+ const session = new PwshSession(handle, options.label ?? 'pwsh', options.maxOutputBytes)
216
+ let timer
217
+ try {
218
+ const probe = await Promise.race([
219
+ abortable(session.run('$PSVersionTable.PSVersion.Major', 15000), options.signal),
220
+ new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('startup handshake timed out')), options.startupTimeoutMs ?? 20000) }),
221
+ ])
222
+ if (probe.sentinel !== 'ok' || session.dead) throw new Error('startup handshake failed: ' + [...probe.exceptions, ...probe.fatal].join('\n'))
223
+ session.version = probe.out.join('\n').trim()
224
+ session.startupWarnings.push(...probe.warn)
225
+ return session
226
+ } catch (error) {
227
+ await session.dispose('pwsh session startup failed')
217
228
  const tail = session.stderrTail.trim()
218
- await session.dispose()
219
- throw new Error('pwsh session failed to start' + (tail.length > 0 ? ': ' + tail : ''))
220
- }
221
- session.version = (probe.out ?? []).join('\n').trim()
222
- return session
229
+ throw new Error('pwsh session failed to start: ' + (error?.message ?? error) + (tail ? '\n' + tail : ''))
230
+ } finally { clearTimeout(timer) }
223
231
  }