dsh-vscode-mode 0.9.0 → 0.11.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.md +42 -3
- package/lib/client.js +1587 -301
- package/lib/client.js.map +1 -1
- package/lib/index.js +2163 -1042
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/client/addToConversation.ts +74 -28
- package/src/client/dap/hover.ts +2 -7
- package/src/client/index.ts +25 -0
- package/src/client/luaReservedWords.ts +10 -0
- package/src/client/monaco/lsp/keywords.ts +121 -0
- package/src/client/monaco/lsp/lspClient.ts +68 -6
- package/src/client/monaco/lsp/memberScope.ts +90 -0
- package/src/client/monaco/lsp/providers.ts +14 -4
- package/src/client/settingsDocRouter.ts +52 -0
- package/src/client/sidebar/menuItems.ts +1 -0
- package/src/client/sidebar/panels/SvnPanel.ts +1 -1
- package/src/client/sidebar/types.ts +2 -0
- package/src/client/styles/editor.css +143 -37
- package/src/client/svnActions.ts +4 -4
- package/src/client/ui/ContextMenu.ts +4 -2
- package/src/client/ui/EditorView.ts +132 -52
- package/src/client/ui/SvnLogDialog.ts +200 -69
- package/src/client/ui/SvnUpdateDialog.ts +472 -0
- package/src/client/ui/svnUpdateModel.ts +135 -0
- package/src/index.ts +2 -0
- package/src/lsp/completionCap.ts +80 -0
- package/src/lsp/manager.ts +42 -10
- package/src/lsp/rpc.ts +8 -5
- package/src/lsp/server.ts +18 -4
- package/src/rpc.ts +74 -2
- package/src/settingsDocPath.ts +30 -0
- package/src/shared/rpc.ts +12 -2
- package/src/shared/svn.ts +38 -0
- package/src/svn.ts +102 -7
- package/src/svnText.ts +22 -13
- package/src/svnUpdateJob.ts +588 -0
|
@@ -0,0 +1,588 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-vscode-mode host — SVN update job with incremental output and bounded replay.
|
|
3
|
+
* @author ddj 2026年09月24号
|
|
4
|
+
*/
|
|
5
|
+
import { randomUUID } from 'node:crypto'
|
|
6
|
+
import { isAbsolute, relative, resolve } from 'node:path'
|
|
7
|
+
import type { Readable } from 'node:stream'
|
|
8
|
+
import type { SvnUpdateCounts, SvnUpdatePhase, SvnUpdatePoll, SvnUpdateRow, SvnUpdateState } from './shared/svn.js'
|
|
9
|
+
import { updateLineOf } from './svnText.js'
|
|
10
|
+
|
|
11
|
+
const ROW_CAP = 20_000
|
|
12
|
+
const PAGE_SIZE = 250
|
|
13
|
+
const RAW_CAP = 32_000
|
|
14
|
+
const LINE_CAP = 16_000
|
|
15
|
+
const JOB_CAP = 16
|
|
16
|
+
const RESULT_TTL_MS = 10 * 60_000
|
|
17
|
+
const UPDATE_MAX_MS = 60 * 60_000
|
|
18
|
+
const COLLECT_MS = 100
|
|
19
|
+
|
|
20
|
+
interface OutputRead { text: string; nextOffset: number; lossy: boolean }
|
|
21
|
+
interface OutputReader { readFrom(offset: number): OutputRead }
|
|
22
|
+
interface ExitFacts { exitCode: number | null; code?: number | null }
|
|
23
|
+
interface ManagedHandle {
|
|
24
|
+
done: Promise<ExitFacts>
|
|
25
|
+
terminate(): void
|
|
26
|
+
collected?: { stdout?: OutputReader; stderr?: OutputReader }
|
|
27
|
+
}
|
|
28
|
+
interface TerminalHandle {
|
|
29
|
+
done: Promise<ExitFacts>
|
|
30
|
+
terminate(): Promise<void>
|
|
31
|
+
output: Readable
|
|
32
|
+
}
|
|
33
|
+
export interface UpdateProcess {
|
|
34
|
+
spawn(spec: Record<string, unknown>): ManagedHandle
|
|
35
|
+
spawnTerminal?: (spec: Record<string, unknown>) => Promise<TerminalHandle>
|
|
36
|
+
}
|
|
37
|
+
export interface UpdateTarget {
|
|
38
|
+
cwd: string
|
|
39
|
+
root: string
|
|
40
|
+
path: string
|
|
41
|
+
exe: string
|
|
42
|
+
}
|
|
43
|
+
interface UpdateJob {
|
|
44
|
+
id: string
|
|
45
|
+
root: string
|
|
46
|
+
cwd: string
|
|
47
|
+
target: string
|
|
48
|
+
phase: SvnUpdatePhase
|
|
49
|
+
counts: SvnUpdateCounts
|
|
50
|
+
revision: number | null
|
|
51
|
+
error: string
|
|
52
|
+
rawTail: string
|
|
53
|
+
truncated: boolean
|
|
54
|
+
startedAt: number
|
|
55
|
+
endedAt: number | null
|
|
56
|
+
rows: SvnUpdateRow[]
|
|
57
|
+
seq: number
|
|
58
|
+
pending: string
|
|
59
|
+
cancelled: boolean
|
|
60
|
+
exited: boolean
|
|
61
|
+
probeAbort?: AbortController
|
|
62
|
+
timer?: ReturnType<typeof setInterval>
|
|
63
|
+
deadline?: ReturnType<typeof setTimeout>
|
|
64
|
+
stop?: () => void
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* 创建每个任务独立的 SVN 状态计数。
|
|
69
|
+
* @author ddj 2026年09月24号
|
|
70
|
+
* @returns 初始计数
|
|
71
|
+
*/
|
|
72
|
+
function emptyCounts(): SvnUpdateCounts {
|
|
73
|
+
return { A: 0, U: 0, D: 0, G: 0, C: 0, E: 0, R: 0 }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* 工作副本根作为并发键;Windows 路径大小写不敏感。
|
|
78
|
+
* @author ddj 2026年09月24号
|
|
79
|
+
* @param root 工作副本根
|
|
80
|
+
* @param platform 目标平台
|
|
81
|
+
* @returns 并发键
|
|
82
|
+
*/
|
|
83
|
+
function rootKey(root: string, platform: NodeJS.Platform): string {
|
|
84
|
+
return platform === 'win32' ? root.toLowerCase() : root
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* 有状态更新任务:原 RPC 与新后台任务独立,测试可注入进程服务/时钟。
|
|
89
|
+
* @author ddj 2026年09月24号
|
|
90
|
+
*/
|
|
91
|
+
export class SvnUpdateJobs {
|
|
92
|
+
private readonly jobs = new Map<string, UpdateJob>()
|
|
93
|
+
private readonly platform: NodeJS.Platform
|
|
94
|
+
private readonly now: () => number
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* 建立任务管理器。
|
|
98
|
+
* @author ddj 2026年09月24号
|
|
99
|
+
* @param service DSH subprocess 服务获取器
|
|
100
|
+
* @param platform 平台
|
|
101
|
+
* @param now 时钟
|
|
102
|
+
*/
|
|
103
|
+
constructor(
|
|
104
|
+
private readonly service: () => UpdateProcess | null,
|
|
105
|
+
platform: NodeJS.Platform = process.platform,
|
|
106
|
+
now: () => number = Date.now,
|
|
107
|
+
) {
|
|
108
|
+
this.platform = platform
|
|
109
|
+
this.now = now
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* 同根重复点击只返回当前任务;旧结果可由 active 取回,新点击启动新任务。
|
|
114
|
+
* @author ddj 2026年09月24号
|
|
115
|
+
* @param target 已验证的工作副本目标
|
|
116
|
+
* @returns 任务初始状态
|
|
117
|
+
*/
|
|
118
|
+
start(target: UpdateTarget): SvnUpdateState {
|
|
119
|
+
this.prune()
|
|
120
|
+
const key = rootKey(target.root, this.platform)
|
|
121
|
+
const old = this.jobs.get(key)
|
|
122
|
+
if (old && !this.isFinal(old.phase)) return this.stateOf(old)
|
|
123
|
+
if (!old && this.jobs.size >= JOB_CAP) throw new Error('同时保留的 SVN 更新任务过多,请稍后重试')
|
|
124
|
+
const job: UpdateJob = {
|
|
125
|
+
id: randomUUID(), root: target.root, cwd: target.cwd, target: target.path,
|
|
126
|
+
phase: 'starting', counts: emptyCounts(), revision: null, error: '', rawTail: '',
|
|
127
|
+
truncated: false, startedAt: this.now(), endedAt: null, rows: [], seq: 0,
|
|
128
|
+
pending: '', cancelled: false, exited: false,
|
|
129
|
+
}
|
|
130
|
+
this.jobs.set(key, job)
|
|
131
|
+
void this.launch(job, target.exe)
|
|
132
|
+
return this.stateOf(job)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* 游标分页取状态;即使前端掉线,有限窗口仍可重新读取。
|
|
137
|
+
* @author ddj 2026年09月24号
|
|
138
|
+
* @param root 已验证会话的工作副本根
|
|
139
|
+
* @param jobId 任务 id
|
|
140
|
+
* @param since 最后一条已读序号
|
|
141
|
+
* @returns 一页增量及状态
|
|
142
|
+
*/
|
|
143
|
+
poll(root: string, jobId: string, since: number): SvnUpdatePoll {
|
|
144
|
+
const job = this.getJob(root, jobId)
|
|
145
|
+
const offset = job.rows[0]?.seq ?? job.seq + 1
|
|
146
|
+
const cursor = Math.max(offset - 1, Number.isFinite(since) ? Math.floor(since) : 0)
|
|
147
|
+
const start = Math.max(0, cursor - offset + 1)
|
|
148
|
+
const rows = job.rows.slice(start, start + PAGE_SIZE)
|
|
149
|
+
return { ...this.stateOf(job), rows, offset, totalSeq: job.seq, nextSeq: rows.at(-1)?.seq ?? cursor }
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* 请求终止整个受管进程范围;真实退出前保持 cancelling 状态。
|
|
154
|
+
* @author ddj 2026年09月24号
|
|
155
|
+
* @param root 已验证会话的工作副本根
|
|
156
|
+
* @param jobId 任务 id
|
|
157
|
+
* @returns 当前状态
|
|
158
|
+
*/
|
|
159
|
+
cancel(root: string, jobId: string): SvnUpdateState {
|
|
160
|
+
const job = this.getJob(root, jobId)
|
|
161
|
+
if (!this.isFinal(job.phase) && !job.exited) {
|
|
162
|
+
job.cancelled = true
|
|
163
|
+
job.phase = 'cancelling'
|
|
164
|
+
job.probeAbort?.abort(new Error('用户已取消 SVN 更新'))
|
|
165
|
+
try { job.stop?.() } catch (error) { job.error = String(error) }
|
|
166
|
+
}
|
|
167
|
+
return this.stateOf(job)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* 按当前会话工作副本恢复进行中或最近一次结果。
|
|
172
|
+
* @author ddj 2026年09月24号
|
|
173
|
+
* @param root 已验证的工作副本根
|
|
174
|
+
* @returns 状态或 null
|
|
175
|
+
*/
|
|
176
|
+
active(root: string): SvnUpdateState | null {
|
|
177
|
+
this.prune()
|
|
178
|
+
const job = this.jobs.get(rootKey(root, this.platform))
|
|
179
|
+
return job ? this.stateOf(job) : null
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* 插件卸载回收任务与定时器。
|
|
184
|
+
* @author ddj 2026年09月24号
|
|
185
|
+
*/
|
|
186
|
+
dispose(): void {
|
|
187
|
+
for (const job of this.jobs.values()) {
|
|
188
|
+
if (job.timer) clearInterval(job.timer)
|
|
189
|
+
if (job.deadline) clearTimeout(job.deadline)
|
|
190
|
+
if (!this.isFinal(job.phase)) {
|
|
191
|
+
job.cancelled = true
|
|
192
|
+
job.probeAbort?.abort(new Error('SVN 插件已卸载'))
|
|
193
|
+
try { job.stop?.() } catch { /* 卸载继续清理其它任务 */ }
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
this.jobs.clear()
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* 先用只读 version 探测捕获通道,再允许启动会修改工作副本的 update。
|
|
201
|
+
* @author ddj 2026年09月24号
|
|
202
|
+
* @param job 待启动任务
|
|
203
|
+
* @param exe 已配置 SVN CLI
|
|
204
|
+
*/
|
|
205
|
+
private async launch(job: UpdateJob, exe: string): Promise<void> {
|
|
206
|
+
const controller = new AbortController()
|
|
207
|
+
job.probeAbort = controller
|
|
208
|
+
const probeDeadline = setTimeout(() => controller.abort(new Error('SVN 输出探测超时(8 秒)')), 8000)
|
|
209
|
+
probeDeadline.unref?.()
|
|
210
|
+
try {
|
|
211
|
+
const service = this.service()
|
|
212
|
+
if (!service) throw new Error('subprocess 服务不可用')
|
|
213
|
+
const modes = this.platform === 'win32' ? ['terminal', 'collect'] : ['collect', 'terminal']
|
|
214
|
+
let mode: string | null = null
|
|
215
|
+
let reason = ''
|
|
216
|
+
for (const candidate of modes) {
|
|
217
|
+
controller.signal.throwIfAborted()
|
|
218
|
+
try { await this.probe(service, exe, job.cwd, candidate, controller.signal); mode = candidate; break }
|
|
219
|
+
catch (error) {
|
|
220
|
+
if (controller.signal.aborted) throw error
|
|
221
|
+
reason = String(error)
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
if (job.cancelled) { this.finish(job, null); return }
|
|
225
|
+
if (!mode) throw new Error('SVN 输出通道不可用:' + reason)
|
|
226
|
+
clearTimeout(probeDeadline)
|
|
227
|
+
await this.startUpdate(job, service, mode, exe, controller)
|
|
228
|
+
} catch (error) {
|
|
229
|
+
job.error = String(error)
|
|
230
|
+
this.finish(job, null)
|
|
231
|
+
} finally {
|
|
232
|
+
clearTimeout(probeDeadline)
|
|
233
|
+
job.probeAbort = undefined
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* 分配实际更新句柄时继续允许取消;未完成的分配不盲目重试更新。
|
|
239
|
+
* @author ddj 2026年09月24号
|
|
240
|
+
* @param job 当前任务
|
|
241
|
+
* @param service DSH 子进程服务
|
|
242
|
+
* @param mode 已探测可用的流式通道
|
|
243
|
+
* @param exe SVN 可执行文件
|
|
244
|
+
* @param controller 启动阶段的取消信号
|
|
245
|
+
*/
|
|
246
|
+
private async startUpdate(job: UpdateJob, service: UpdateProcess, mode: string, exe: string, controller: AbortController): Promise<void> {
|
|
247
|
+
const argv = [exe, '--non-interactive', 'update', '--', job.target || '.']
|
|
248
|
+
job.phase = 'running'
|
|
249
|
+
job.stop = () => controller.abort(new Error('更新启动期间被取消'))
|
|
250
|
+
job.deadline = setTimeout(() => {
|
|
251
|
+
job.error = 'SVN 更新超过 60 分钟,已请求停止;工作副本可能已部分更新'
|
|
252
|
+
try { job.stop?.() } catch { /* 退出路径仍会报告错误 */ }
|
|
253
|
+
}, UPDATE_MAX_MS)
|
|
254
|
+
job.deadline.unref?.()
|
|
255
|
+
if (mode === 'terminal' && service.spawnTerminal) {
|
|
256
|
+
const pending = service.spawnTerminal({ ...this.terminalSpec(argv, job.cwd), signal: controller.signal })
|
|
257
|
+
const handle = await this.awaitTerminal(pending, controller.signal)
|
|
258
|
+
job.stop = () => { void handle.terminate().catch((error) => { job.error = String(error) }) }
|
|
259
|
+
if (job.cancelled) job.stop()
|
|
260
|
+
this.watchTerminal(job, handle)
|
|
261
|
+
} else {
|
|
262
|
+
const handle = service.spawn({ ...this.collectSpec(argv, job.cwd), signal: controller.signal })
|
|
263
|
+
job.stop = () => handle.terminate()
|
|
264
|
+
if (job.cancelled) job.stop()
|
|
265
|
+
this.watchCollect(job, handle)
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* 终端句柄分配与 Abort 之间的微任务间隙也必须回收晚到句柄。
|
|
271
|
+
* @author ddj 2026年09月24号
|
|
272
|
+
* @param pending 分配中的终端
|
|
273
|
+
* @param signal 用户取消或截止信号
|
|
274
|
+
* @returns 可受管的终端句柄
|
|
275
|
+
*/
|
|
276
|
+
private async awaitTerminal(pending: Promise<TerminalHandle>, signal: AbortSignal): Promise<TerminalHandle> {
|
|
277
|
+
let handle: TerminalHandle | null = null
|
|
278
|
+
const stop = () => { if (handle) void handle.terminate().catch(() => {}) }
|
|
279
|
+
signal.addEventListener('abort', stop, { once: true })
|
|
280
|
+
try {
|
|
281
|
+
const guarded = pending.then((ready) => {
|
|
282
|
+
handle = ready
|
|
283
|
+
if (signal.aborted) stop()
|
|
284
|
+
return ready
|
|
285
|
+
})
|
|
286
|
+
return await this.waitProbe(guarded, signal, () => {})
|
|
287
|
+
} finally {
|
|
288
|
+
signal.removeEventListener('abort', stop)
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* 探针/启动 Promise 可由超时和用户取消立即打断,并终止已分配的句柄。
|
|
294
|
+
* @author ddj 2026年09月24号
|
|
295
|
+
* @param pending 待完成操作
|
|
296
|
+
* @param signal 任务取消信号
|
|
297
|
+
* @param stop 停止已经分配的子进程
|
|
298
|
+
* @returns 操作结果
|
|
299
|
+
*/
|
|
300
|
+
private waitProbe<T>(pending: Promise<T>, signal: AbortSignal, stop: () => void): Promise<T> {
|
|
301
|
+
return new Promise<T>((resolve, reject) => {
|
|
302
|
+
let finished = false
|
|
303
|
+
const abort = () => {
|
|
304
|
+
if (finished) return
|
|
305
|
+
finished = true
|
|
306
|
+
try { stop() } catch { /* 使用取消原因报告 */ }
|
|
307
|
+
reject(signal.reason ?? new Error('SVN 探测已取消'))
|
|
308
|
+
}
|
|
309
|
+
if (signal.aborted) { abort(); void pending.catch(() => {}); return }
|
|
310
|
+
signal.addEventListener('abort', abort, { once: true })
|
|
311
|
+
pending.then((value) => {
|
|
312
|
+
signal.removeEventListener('abort', abort)
|
|
313
|
+
if (!finished) { finished = true; resolve(value) }
|
|
314
|
+
}, (error) => {
|
|
315
|
+
signal.removeEventListener('abort', abort)
|
|
316
|
+
if (!finished) { finished = true; reject(error) }
|
|
317
|
+
})
|
|
318
|
+
})
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* 探针失败只换读法,不会重试一次已启动的实际 update。
|
|
323
|
+
* @author ddj 2026年09月24号
|
|
324
|
+
* @param service DSH 服务
|
|
325
|
+
* @param exe SVN 命令
|
|
326
|
+
* @param cwd 进程目录
|
|
327
|
+
* @param mode 输出通道
|
|
328
|
+
* @param signal 用户取消或探针超时
|
|
329
|
+
*/
|
|
330
|
+
private async probe(service: UpdateProcess, exe: string, cwd: string, mode: string, signal: AbortSignal): Promise<void> {
|
|
331
|
+
signal.throwIfAborted()
|
|
332
|
+
const argv = [exe, '--version', '--quiet']
|
|
333
|
+
if (mode === 'terminal') {
|
|
334
|
+
if (!service.spawnTerminal) throw new Error('终端输出通道不可用')
|
|
335
|
+
const pending = service.spawnTerminal({ ...this.terminalSpec(argv, cwd), signal })
|
|
336
|
+
const handle = await this.awaitTerminal(pending, signal)
|
|
337
|
+
let text = ''
|
|
338
|
+
let failure = ''
|
|
339
|
+
const drained = new Promise<void>((resolve) => {
|
|
340
|
+
handle.output.on('data', (chunk) => { text += String(chunk) })
|
|
341
|
+
handle.output.once('end', resolve)
|
|
342
|
+
handle.output.once('error', (error) => { failure = String(error); resolve() })
|
|
343
|
+
})
|
|
344
|
+
const outcome = await this.waitProbe(handle.done, signal, () => { void handle.terminate().catch(() => {}) })
|
|
345
|
+
await this.waitProbe(Promise.race([drained, new Promise<void>((resolve) => {
|
|
346
|
+
const timer = setTimeout(resolve, 2000)
|
|
347
|
+
timer.unref?.()
|
|
348
|
+
})]), signal, () => { void handle.terminate().catch(() => {}) })
|
|
349
|
+
if (outcome.exitCode !== 0 || !text.trim() || failure) throw new Error('终端输出探测失败:' + failure)
|
|
350
|
+
return
|
|
351
|
+
}
|
|
352
|
+
const handle = service.spawn({ ...this.collectSpec(argv, cwd), signal })
|
|
353
|
+
const outcome = await this.waitProbe(handle.done, signal, () => handle.terminate())
|
|
354
|
+
const text = handle.collected?.stdout?.readFrom(0).text ?? ''
|
|
355
|
+
if (outcome.exitCode !== 0 || !text.trim()) throw new Error('收集输出探测失败')
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* DSH PTY 读取:TextDecoder 保证跨数据块的 UTF-8 字符不被截断。
|
|
360
|
+
* @author ddj 2026年09月24号
|
|
361
|
+
* @param job 活动任务
|
|
362
|
+
* @param handle 终端句柄
|
|
363
|
+
*/
|
|
364
|
+
private watchTerminal(job: UpdateJob, handle: TerminalHandle): void {
|
|
365
|
+
const decoder = new TextDecoder()
|
|
366
|
+
let drained = false
|
|
367
|
+
let finishOutput: () => void = () => {}
|
|
368
|
+
const outputDone = new Promise<void>((resolve) => { finishOutput = resolve })
|
|
369
|
+
handle.output.on('data', (chunk: Buffer | string) => {
|
|
370
|
+
const text = typeof chunk === 'string' ? chunk : decoder.decode(chunk, { stream: true })
|
|
371
|
+
this.ingest(job, text)
|
|
372
|
+
})
|
|
373
|
+
handle.output.on('end', () => { drained = true; this.ingest(job, decoder.decode()); finishOutput() })
|
|
374
|
+
handle.output.on('error', (error) => { job.error = '读取输出失败:' + String(error); finishOutput() })
|
|
375
|
+
void handle.done.then(async (outcome) => {
|
|
376
|
+
job.exited = true
|
|
377
|
+
if (!drained) {
|
|
378
|
+
await Promise.race([outputDone, new Promise<void>((resolve) => {
|
|
379
|
+
const timeout = setTimeout(resolve, 2000)
|
|
380
|
+
timeout.unref?.()
|
|
381
|
+
})])
|
|
382
|
+
}
|
|
383
|
+
this.finish(job, outcome.exitCode)
|
|
384
|
+
}, (error) => { job.exited = true; job.error = String(error); this.finish(job, null) })
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* 管道收集的读取器独立管理 stdout/stderr 字节偏移;只消费完整行,避免截断中文。
|
|
389
|
+
* @author ddj 2026年09月24号
|
|
390
|
+
* @param job 活动任务
|
|
391
|
+
* @param handle 子进程句柄
|
|
392
|
+
*/
|
|
393
|
+
private watchCollect(job: UpdateJob, handle: ManagedHandle): void {
|
|
394
|
+
const offsets = { stdout: 0, stderr: 0 }
|
|
395
|
+
job.timer = setInterval(() => this.readCollect(job, handle, offsets, false), COLLECT_MS)
|
|
396
|
+
job.timer.unref?.()
|
|
397
|
+
void handle.done.then((outcome) => {
|
|
398
|
+
job.exited = true
|
|
399
|
+
if (job.timer) clearInterval(job.timer)
|
|
400
|
+
this.readCollect(job, handle, offsets, true)
|
|
401
|
+
this.finish(job, outcome.exitCode ?? outcome.code ?? null)
|
|
402
|
+
}, (error) => { job.exited = true; job.error = String(error); this.finish(job, null) })
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* 从两个 bounded collector 取完整行;lossy 时标记不完整且不伪造丢失计数。
|
|
407
|
+
* @author ddj 2026年09月24号
|
|
408
|
+
* @param job 活动任务
|
|
409
|
+
* @param handle 子进程句柄
|
|
410
|
+
* @param offsets 各路已消费字节位置
|
|
411
|
+
* @param final 进程已结束,可以消费末尾无换行内容
|
|
412
|
+
*/
|
|
413
|
+
private readCollect(job: UpdateJob, handle: ManagedHandle, offsets: { stdout: number; stderr: number }, final: boolean): void {
|
|
414
|
+
for (const key of ['stdout', 'stderr'] as const) {
|
|
415
|
+
const reader = handle.collected?.[key]
|
|
416
|
+
if (!reader) { job.truncated = true; continue }
|
|
417
|
+
const read = reader.readFrom(offsets[key])
|
|
418
|
+
if (read.lossy) {
|
|
419
|
+
job.truncated = true
|
|
420
|
+
offsets[key] = read.nextOffset
|
|
421
|
+
job.pending = ''
|
|
422
|
+
this.recordLine(job, '输出前段已截断,以下是可恢复的尾部记录', null)
|
|
423
|
+
const firstEnd = read.text.indexOf('\n')
|
|
424
|
+
if (firstEnd >= 0) this.ingest(job, read.text.slice(firstEnd + 1))
|
|
425
|
+
continue
|
|
426
|
+
}
|
|
427
|
+
const last = final ? read.text.length : read.text.lastIndexOf('\n') + 1
|
|
428
|
+
if (!last) continue
|
|
429
|
+
const chunk = read.text.slice(0, last)
|
|
430
|
+
offsets[key] = final ? read.nextOffset : offsets[key] + Buffer.byteLength(chunk, 'utf8')
|
|
431
|
+
this.ingest(job, chunk)
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* 增量行缓冲:ANSI 和 CR/LF 归一;过长单行主动截断并告警。
|
|
437
|
+
* @author ddj 2026年09月24号
|
|
438
|
+
* @param job 活动任务
|
|
439
|
+
* @param text 新输出
|
|
440
|
+
*/
|
|
441
|
+
private ingest(job: UpdateJob, text: string): void {
|
|
442
|
+
if (!text) return
|
|
443
|
+
job.pending += text.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '')
|
|
444
|
+
const lines = job.pending.split(/\r\n|\n|\r/)
|
|
445
|
+
job.pending = lines.pop() ?? ''
|
|
446
|
+
for (const line of lines) this.recordLine(job, line, updateLineOf(line))
|
|
447
|
+
if (job.pending.length > LINE_CAP) {
|
|
448
|
+
job.truncated = true
|
|
449
|
+
this.recordLine(job, job.pending.slice(0, LINE_CAP) + '…(单行过长)', null)
|
|
450
|
+
job.pending = ''
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* 记录有界行与原文;修订号只读 svn 自己的完成/无变化行。
|
|
456
|
+
* @author ddj 2026年09月24号
|
|
457
|
+
* @param job 活动任务
|
|
458
|
+
* @param line 完整输出行
|
|
459
|
+
* @param entry 可识别的逐文件状态
|
|
460
|
+
*/
|
|
461
|
+
private recordLine(job: UpdateJob, line: string, entry: ReturnType<typeof updateLineOf>): void {
|
|
462
|
+
const raw = line.trimEnd()
|
|
463
|
+
if (!raw) return
|
|
464
|
+
job.rawTail = (job.rawTail + raw + '\n').slice(-RAW_CAP)
|
|
465
|
+
const rev = /(?:Updated to revision|At revision|已更新到版本|版本)\s*\.?\s*(\d+)/i.exec(raw)
|
|
466
|
+
if (rev) job.revision = Number(rev[1])
|
|
467
|
+
if (entry) job.counts[entry.action] += 1
|
|
468
|
+
const path = entry?.path ?? ''
|
|
469
|
+
const row: SvnUpdateRow = {
|
|
470
|
+
seq: ++job.seq, action: entry?.action ?? null, path,
|
|
471
|
+
relPath: entry ? this.relPathOf(job, path) : null,
|
|
472
|
+
label: entry?.label ?? raw.trim(), raw,
|
|
473
|
+
}
|
|
474
|
+
job.rows.push(row)
|
|
475
|
+
if (job.rows.length > ROW_CAP) { job.rows.splice(0, 1000); job.truncated = true }
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* 只给工作区内的输出路径开放文件动作(不信任 CLI 文本直接当作目标)。
|
|
480
|
+
* @author ddj 2026年09月24号
|
|
481
|
+
* @param job 活动任务
|
|
482
|
+
* @param path SVN 输出路径
|
|
483
|
+
* @returns 工作区相对路径或 null
|
|
484
|
+
*/
|
|
485
|
+
private relPathOf(job: UpdateJob, path: string): string | null {
|
|
486
|
+
const abs = resolve(job.cwd, path)
|
|
487
|
+
const wcRel = relative(job.root, abs)
|
|
488
|
+
const rel = relative(job.cwd, abs)
|
|
489
|
+
if (isAbsolute(wcRel) || wcRel === '..' || wcRel.startsWith('..\\') || wcRel.startsWith('../')) return null
|
|
490
|
+
if (isAbsolute(rel) || rel === '..' || rel.startsWith('..\\') || rel.startsWith('../')) return null
|
|
491
|
+
return rel.replace(/\\/g, '/') || null
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* 保留终态行供用户检查,取消不能误报成功。
|
|
496
|
+
* @author ddj 2026年09月24号
|
|
497
|
+
* @param job 活动任务
|
|
498
|
+
* @param code 真实退出码
|
|
499
|
+
*/
|
|
500
|
+
private finish(job: UpdateJob, code: number | null): void {
|
|
501
|
+
if (this.isFinal(job.phase)) return
|
|
502
|
+
if (job.pending.trim()) this.recordLine(job, job.pending, updateLineOf(job.pending))
|
|
503
|
+
job.pending = ''
|
|
504
|
+
if (job.timer) { clearInterval(job.timer); job.timer = undefined }
|
|
505
|
+
if (job.deadline) { clearTimeout(job.deadline); job.deadline = undefined }
|
|
506
|
+
job.endedAt = this.now()
|
|
507
|
+
if (job.cancelled) job.phase = 'cancelled'
|
|
508
|
+
else if (code === 0 && !job.error) job.phase = 'completed'
|
|
509
|
+
else {
|
|
510
|
+
job.phase = 'failed'
|
|
511
|
+
job.error ||= 'SVN 更新失败(退出码 ' + String(code) + '):' + job.rawTail.slice(-500)
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* 快照不暴露可变数组;耗时使用现场时钟或终态时间。
|
|
517
|
+
* @author ddj 2026年09月24号
|
|
518
|
+
* @param job 目标任务
|
|
519
|
+
* @returns 纯数据快照
|
|
520
|
+
*/
|
|
521
|
+
private stateOf(job: UpdateJob): SvnUpdateState {
|
|
522
|
+
return {
|
|
523
|
+
jobId: job.id, target: job.target, phase: job.phase, counts: { ...job.counts },
|
|
524
|
+
revision: job.revision, elapsedMs: Math.max(0, (job.endedAt ?? this.now()) - job.startedAt),
|
|
525
|
+
error: job.error, rawTail: this.isFinal(job.phase) || job.truncated ? job.rawTail : '',
|
|
526
|
+
truncated: job.truncated, nextSeq: job.seq,
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* 任务 ID 必须属于调用会话的工作副本根。
|
|
532
|
+
* @author ddj 2026年09月24号
|
|
533
|
+
* @param root 已验证工作副本根
|
|
534
|
+
* @param jobId 客户端任务 id
|
|
535
|
+
* @returns job
|
|
536
|
+
*/
|
|
537
|
+
private getJob(root: string, jobId: string): UpdateJob {
|
|
538
|
+
this.prune()
|
|
539
|
+
const job = this.jobs.get(rootKey(root, this.platform))
|
|
540
|
+
if (!job || job.id !== jobId) throw new Error('SVN 更新任务不存在或工作区不匹配')
|
|
541
|
+
return job
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* 清理过期终态,不丢活动中的更新。
|
|
546
|
+
* @author ddj 2026年09月24号
|
|
547
|
+
*/
|
|
548
|
+
private prune(): void {
|
|
549
|
+
for (const [key, job] of this.jobs) {
|
|
550
|
+
if (job.endedAt !== null && this.now() - job.endedAt > RESULT_TTL_MS) this.jobs.delete(key)
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* 终态判断集中定义,避免取消请求被误视作结束。
|
|
556
|
+
* @author ddj 2026年09月24号
|
|
557
|
+
* @param phase 当前阶段
|
|
558
|
+
* @returns 是否结束
|
|
559
|
+
*/
|
|
560
|
+
private isFinal(phase: SvnUpdatePhase): boolean {
|
|
561
|
+
return phase === 'completed' || phase === 'failed' || phase === 'cancelled'
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* 不启 shell:每个 argv 直接传入,标准输入忽略且提供进程清理预算。
|
|
566
|
+
* @author ddj 2026年09月24号
|
|
567
|
+
* @param argv 可执行文件及参数
|
|
568
|
+
* @param cwd 目标工作目录
|
|
569
|
+
* @returns DSH terminal spec
|
|
570
|
+
*/
|
|
571
|
+
private terminalSpec(argv: string[], cwd: string): Record<string, unknown> {
|
|
572
|
+
return { argv, cwd, rows: 30, cols: 180, terminalType: 'xterm-256color', graceMs: 10_000 }
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* collected reader 大于单次 RPC:后台按 100ms 消费,避免 1MB 尾部静默丢头。
|
|
577
|
+
* @author ddj 2026年09月24号
|
|
578
|
+
* @param argv 可执行文件及参数
|
|
579
|
+
* @param cwd 目标工作目录
|
|
580
|
+
* @returns DSH process spec
|
|
581
|
+
*/
|
|
582
|
+
private collectSpec(argv: string[], cwd: string): Record<string, unknown> {
|
|
583
|
+
return {
|
|
584
|
+
argv, cwd, graceMs: 10_000,
|
|
585
|
+
stdio: { stdin: 'ignore', stdout: { maxBytes: 4 * 1024 * 1024 }, stderr: { maxBytes: 128 * 1024 } },
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
}
|