Replace the restricted workspace_rg parameter mapping with native args and optional cwd. Preserve rg output and exit codes, and reuse process cancellation and output_read pagination so large search results remain accessible. Ask remains read-only within the workspace; Execute uses current-account permissions. Validation: focused regression 169 passed, 1 skipped; typecheck and lint passed. Real DeepSeek Ask and Execute pagination scenarios passed with six counted requests in the successful run; an earlier failed fixture run has no retained request count. Full-suite validation is not green: the recorded run had unrelated failures and the latest run timed out without a summary. The remote Agent does not use this local execution path. Release note: 直连模型可使用 ripgrep 原生参数搜索文件并保留原生结果,大输出可通过 output_read 完整续读。Ask 保持工作区只读,Execute 使用当前账号权限。
693 lines
20 KiB
TypeScript
693 lines
20 KiB
TypeScript
import { constants as fileSystemConstants } from 'node:fs'
|
|
import { access, realpath, stat } from 'node:fs/promises'
|
|
import {
|
|
isAbsolute,
|
|
relative,
|
|
resolve,
|
|
sep,
|
|
win32
|
|
} from 'node:path'
|
|
import spawn from 'cross-spawn'
|
|
import { z } from 'zod'
|
|
import type { WorkspaceAccess } from '../workspace'
|
|
import {
|
|
terminateProcessTreeAndWait,
|
|
type WaitableProcessTreeChild
|
|
} from './child-process-termination'
|
|
import { buildCredentialFilteredUserEnvironment } from './process-environment'
|
|
import {
|
|
PagedOutputStore,
|
|
type PagedOutputPage,
|
|
type PagedOutputReference,
|
|
type PagedOutputWriter
|
|
} from './paged-output-store'
|
|
|
|
const DEFAULT_TIMEOUT_MS = 120_000
|
|
const MAXIMUM_TIMEOUT_MS = 10 * 60_000
|
|
const MAXIMUM_OUTPUT_BYTES = 96 * 1024
|
|
const TERMINATION_WAIT_MS = 2_000
|
|
|
|
export const processExecuteInputSchema = z
|
|
.object({
|
|
command: z.string().trim().min(1).max(100_000),
|
|
cwd: z
|
|
.string()
|
|
.trim()
|
|
.min(1)
|
|
.max(4_096)
|
|
.describe(
|
|
'工作目录,可使用绝对路径或相对于当前工作区的路径;省略时使用工作区根目录,也可选择工作区外目录。'
|
|
)
|
|
.optional(),
|
|
timeoutMs: z
|
|
.number()
|
|
.int()
|
|
.min(1_000)
|
|
.max(MAXIMUM_TIMEOUT_MS)
|
|
.default(DEFAULT_TIMEOUT_MS)
|
|
})
|
|
.strict()
|
|
|
|
export type ProcessExecuteInput = z.input<
|
|
typeof processExecuteInputSchema
|
|
>
|
|
|
|
export type ProcessShellSummary = {
|
|
kind: 'powershell' | 'bash' | 'sh'
|
|
label: string
|
|
}
|
|
|
|
export type ProcessExecuteResult = {
|
|
shell: ProcessShellSummary
|
|
cwd: string
|
|
exitCode: number | null
|
|
signal?: string
|
|
terminationReason?: 'timeout' | 'cancelled'
|
|
durationMs: number
|
|
stdout: string
|
|
stderr: string
|
|
stdoutTruncated: boolean
|
|
stderrTruncated: boolean
|
|
stdoutReference?: PagedOutputReference
|
|
stderrReference?: PagedOutputReference
|
|
}
|
|
|
|
export type DirectModelProcessCapability =
|
|
| {
|
|
available: true
|
|
shell: ProcessShellSummary
|
|
}
|
|
| {
|
|
available: false
|
|
reason: string
|
|
}
|
|
|
|
export type DirectModelProcessExecutionContext = {
|
|
conversationId: string
|
|
workspace: WorkspaceAccess
|
|
signal: AbortSignal
|
|
}
|
|
|
|
export interface DirectModelProcessService {
|
|
getCapability(): Promise<DirectModelProcessCapability>
|
|
execute(
|
|
input: ProcessExecuteInput,
|
|
context: DirectModelProcessExecutionContext
|
|
): Promise<ProcessExecuteResult>
|
|
readOutput(ownerId: string, handle: string, cursor?: number, limitBytes?: number): Promise<PagedOutputPage>
|
|
releaseConversation(conversationId: string): Promise<void>
|
|
dispose(): Promise<void>
|
|
}
|
|
|
|
type ProcessOutput = {
|
|
on(event: 'data', listener: (chunk: Buffer | string) => void): unknown
|
|
once(event: 'error', listener: (error: Error) => void): unknown
|
|
removeListener?(
|
|
event: 'error',
|
|
listener: (error: Error) => void
|
|
): unknown
|
|
}
|
|
|
|
export type DirectModelProcessChild = WaitableProcessTreeChild & {
|
|
signalCode?: NodeJS.Signals | null
|
|
stdout?: ProcessOutput | null
|
|
stderr?: ProcessOutput | null
|
|
}
|
|
|
|
export type DirectModelProcessSpawn = (
|
|
executable: string,
|
|
args: string[],
|
|
options: {
|
|
cwd: string
|
|
detached: boolean
|
|
env: NodeJS.ProcessEnv
|
|
shell: false
|
|
stdio: ['ignore', 'pipe', 'pipe']
|
|
windowsHide: true
|
|
}
|
|
) => DirectModelProcessChild
|
|
|
|
export type LocalDirectModelProcessServiceOptions = {
|
|
outputPrefix?: string
|
|
platform?: NodeJS.Platform
|
|
environment?: NodeJS.ProcessEnv
|
|
toolBinDirectory?: string
|
|
spawnProcess?: DirectModelProcessSpawn
|
|
executableExists?: (path: string) => Promise<boolean>
|
|
terminateProcessTree?: typeof terminateProcessTreeAndWait
|
|
now?: () => number
|
|
terminationWaitMs?: number
|
|
}
|
|
|
|
type ResolvedProcessShell = ProcessShellSummary & {
|
|
executable: string
|
|
}
|
|
|
|
type ActiveCall = {
|
|
controller: AbortController
|
|
done: Promise<void>
|
|
finish: () => void
|
|
}
|
|
|
|
/**
|
|
* Preserve the user's ordinary command environment without forwarding
|
|
* credentials inherited by Electron Main for GoodBuddy or model providers.
|
|
*/
|
|
export function buildDirectModelProcessEnvironment(
|
|
source: NodeJS.ProcessEnv = process.env,
|
|
toolBinDirectory?: string,
|
|
platform: NodeJS.Platform = process.platform
|
|
): NodeJS.ProcessEnv {
|
|
const environment = buildCredentialFilteredUserEnvironment(source)
|
|
if (toolBinDirectory) {
|
|
const pathName =
|
|
source.PATH !== undefined
|
|
? 'PATH'
|
|
: source.Path !== undefined
|
|
? 'Path'
|
|
: source.path !== undefined
|
|
? 'path'
|
|
: 'PATH'
|
|
const existingPath = environment[pathName]
|
|
const pathDelimiter = platform === 'win32' ? ';' : ':'
|
|
environment[pathName] = existingPath
|
|
? `${toolBinDirectory}${pathDelimiter}${existingPath}`
|
|
: toolBinDirectory
|
|
}
|
|
return environment
|
|
}
|
|
|
|
async function defaultExecutableExists(path: string): Promise<boolean> {
|
|
try {
|
|
await access(
|
|
path,
|
|
process.platform === 'win32'
|
|
? fileSystemConstants.F_OK
|
|
: fileSystemConstants.X_OK
|
|
)
|
|
return (await stat(path)).isFile()
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
async function findWindowsExecutable(
|
|
name: string,
|
|
environment: NodeJS.ProcessEnv,
|
|
exists: (path: string) => Promise<boolean>
|
|
): Promise<string | undefined> {
|
|
const pathValue =
|
|
environment.PATH ?? environment.Path ?? environment.path ?? ''
|
|
const hasExtension = win32.extname(name) !== ''
|
|
const extensions = hasExtension
|
|
? ['']
|
|
: (environment.PATHEXT ?? '.COM;.EXE;.BAT;.CMD')
|
|
.split(';')
|
|
.filter(Boolean)
|
|
for (const directory of pathValue.split(';').filter(Boolean)) {
|
|
for (const extension of extensions) {
|
|
const candidate = win32.join(directory, `${name}${extension}`)
|
|
if (await exists(candidate)) {
|
|
return candidate
|
|
}
|
|
}
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
async function resolveProcessShell(
|
|
options: LocalDirectModelProcessServiceOptions
|
|
): Promise<ResolvedProcessShell | undefined> {
|
|
const platform = options.platform ?? process.platform
|
|
const environment = buildDirectModelProcessEnvironment(
|
|
options.environment ?? process.env,
|
|
options.toolBinDirectory,
|
|
platform
|
|
)
|
|
const exists = options.executableExists ?? defaultExecutableExists
|
|
if (platform === 'win32') {
|
|
const pwsh = await findWindowsExecutable(
|
|
'pwsh',
|
|
environment,
|
|
exists
|
|
)
|
|
if (pwsh) {
|
|
return {
|
|
executable: pwsh,
|
|
kind: 'powershell',
|
|
label: 'pwsh'
|
|
}
|
|
}
|
|
const windowsPowerShell =
|
|
(await findWindowsExecutable(
|
|
'powershell.exe',
|
|
environment,
|
|
exists
|
|
)) ??
|
|
(await findWindowsExecutable(
|
|
'powershell',
|
|
environment,
|
|
exists
|
|
))
|
|
return windowsPowerShell
|
|
? {
|
|
executable: windowsPowerShell,
|
|
kind: 'powershell',
|
|
label: 'Windows PowerShell'
|
|
}
|
|
: undefined
|
|
}
|
|
if (await exists('/bin/bash')) {
|
|
return {
|
|
executable: '/bin/bash',
|
|
kind: 'bash',
|
|
label: 'Bash'
|
|
}
|
|
}
|
|
if (await exists('/bin/sh')) {
|
|
return {
|
|
executable: '/bin/sh',
|
|
kind: 'sh',
|
|
label: 'sh'
|
|
}
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
function shellArguments(
|
|
shell: ResolvedProcessShell,
|
|
command: string
|
|
): string[] {
|
|
return shell.kind === 'powershell'
|
|
? [
|
|
'-NoLogo',
|
|
'-NoProfile',
|
|
'-NonInteractive',
|
|
'-Command',
|
|
command
|
|
]
|
|
: ['-c', command]
|
|
}
|
|
|
|
function abortError(message: string): DOMException {
|
|
return new DOMException(message, 'AbortError')
|
|
}
|
|
|
|
async function resolveWorkingDirectory(
|
|
input: { cwd?: string },
|
|
workspace: WorkspaceAccess
|
|
): Promise<{ canonicalPath: string; displayPath: string }> {
|
|
if (input.cwd?.includes('\0')) {
|
|
throw new Error('工作目录包含无效字符')
|
|
}
|
|
const initialIdentity = await workspace.getIdentity()
|
|
if (initialIdentity.kind !== 'local') {
|
|
throw new Error('本机进程不能在远程工作区中运行')
|
|
}
|
|
const canonicalRoot = await realpath(
|
|
initialIdentity.canonicalDisplayPath
|
|
)
|
|
if (!(await stat(canonicalRoot)).isDirectory()) {
|
|
throw new Error('项目工作区不是目录')
|
|
}
|
|
const candidatePath = resolve(canonicalRoot, input.cwd ?? '.')
|
|
const canonicalPath = await realpath(candidatePath)
|
|
const canonicalDifference = relative(canonicalRoot, canonicalPath)
|
|
if (!(await stat(canonicalPath)).isDirectory()) {
|
|
throw new Error('工作目录不是目录')
|
|
}
|
|
|
|
const currentIdentity = await workspace.getIdentity()
|
|
if (
|
|
currentIdentity.kind !== initialIdentity.kind ||
|
|
currentIdentity.id !== initialIdentity.id ||
|
|
currentIdentity.canonicalDisplayPath !==
|
|
initialIdentity.canonicalDisplayPath
|
|
) {
|
|
throw new Error('工作区在进程启动前已发生变化')
|
|
}
|
|
return {
|
|
canonicalPath,
|
|
displayPath:
|
|
canonicalDifference === '..' ||
|
|
canonicalDifference.startsWith(`..${sep}`) ||
|
|
isAbsolute(canonicalDifference)
|
|
? canonicalPath
|
|
: canonicalDifference || '.'
|
|
}
|
|
}
|
|
|
|
export class LocalDirectModelProcessService
|
|
implements DirectModelProcessService
|
|
{
|
|
private readonly activeCalls = new Map<string, Set<ActiveCall>>()
|
|
private readonly outputs: PagedOutputStore
|
|
private readonly shell: Promise<ResolvedProcessShell | undefined>
|
|
private disposed = false
|
|
private disposePromise?: Promise<void>
|
|
|
|
constructor(
|
|
private readonly options: LocalDirectModelProcessServiceOptions = {}
|
|
) {
|
|
this.outputs = new PagedOutputStore(options.outputPrefix ?? 'process')
|
|
this.shell = resolveProcessShell(options)
|
|
}
|
|
|
|
async getCapability(): Promise<DirectModelProcessCapability> {
|
|
const shell = await this.shell
|
|
return shell
|
|
? {
|
|
available: true,
|
|
shell: { kind: shell.kind, label: shell.label }
|
|
}
|
|
: {
|
|
available: false,
|
|
reason:
|
|
(this.options.platform ?? process.platform) === 'win32'
|
|
? '未找到可用的 PowerShell'
|
|
: '未找到 /bin/bash 或 /bin/sh'
|
|
}
|
|
}
|
|
|
|
async execute(
|
|
input: ProcessExecuteInput,
|
|
context: DirectModelProcessExecutionContext
|
|
): Promise<ProcessExecuteResult> {
|
|
const parsed = processExecuteInputSchema.parse(input)
|
|
return this.trackExecution(async (signal) => {
|
|
const shell = await this.shell
|
|
signal.throwIfAborted()
|
|
if (!shell) throw new Error('当前平台没有可用的命令 Shell')
|
|
return this.executeTracked({
|
|
executable: shell.executable, args: shellArguments(shell, parsed.command),
|
|
cwd: parsed.cwd, timeoutMs: parsed.timeoutMs, shell
|
|
}, context, signal)
|
|
}, context)
|
|
}
|
|
|
|
async executeFile(
|
|
executable: string,
|
|
args: string[],
|
|
context: DirectModelProcessExecutionContext,
|
|
cwd?: string,
|
|
timeoutMs?: number,
|
|
shell: ProcessShellSummary = { kind: 'sh', label: executable }
|
|
): Promise<ProcessExecuteResult> {
|
|
return this.trackExecution((signal) => this.executeTracked(
|
|
{ executable, args, cwd, timeoutMs, shell }, context, signal
|
|
), context)
|
|
}
|
|
|
|
private async trackExecution(
|
|
run: (signal: AbortSignal) => Promise<ProcessExecuteResult>,
|
|
context: DirectModelProcessExecutionContext
|
|
): Promise<ProcessExecuteResult> {
|
|
if (this.disposed) {
|
|
throw new Error('进程执行服务已关闭')
|
|
}
|
|
|
|
const controller = new AbortController()
|
|
let finishActive!: () => void
|
|
const done = new Promise<void>((resolveDone) => {
|
|
finishActive = resolveDone
|
|
})
|
|
const active: ActiveCall = {
|
|
controller,
|
|
done,
|
|
finish: finishActive
|
|
}
|
|
const calls =
|
|
this.activeCalls.get(context.conversationId) ??
|
|
new Set<ActiveCall>()
|
|
calls.add(active)
|
|
this.activeCalls.set(context.conversationId, calls)
|
|
|
|
const cancelFromCaller = (): void => {
|
|
controller.abort(context.signal.reason)
|
|
}
|
|
context.signal.addEventListener('abort', cancelFromCaller, {
|
|
once: true
|
|
})
|
|
if (context.signal.aborted) {
|
|
cancelFromCaller()
|
|
}
|
|
|
|
try {
|
|
return await run(controller.signal)
|
|
} finally {
|
|
context.signal.removeEventListener('abort', cancelFromCaller)
|
|
calls.delete(active)
|
|
if (calls.size === 0) {
|
|
this.activeCalls.delete(context.conversationId)
|
|
}
|
|
active.finish()
|
|
}
|
|
}
|
|
|
|
private async executeTracked(
|
|
input: { executable: string; args: string[]; cwd?: string; timeoutMs?: number; shell: ProcessShellSummary },
|
|
context: DirectModelProcessExecutionContext,
|
|
signal: AbortSignal
|
|
): Promise<ProcessExecuteResult> {
|
|
signal.throwIfAborted()
|
|
const shell = input.shell
|
|
const workingDirectory = await resolveWorkingDirectory(
|
|
input,
|
|
context.workspace
|
|
)
|
|
signal.throwIfAborted()
|
|
|
|
const platform = this.options.platform ?? process.platform
|
|
const environment = buildDirectModelProcessEnvironment(
|
|
this.options.environment ?? process.env,
|
|
this.options.toolBinDirectory,
|
|
platform
|
|
)
|
|
const startedAt = (this.options.now ?? Date.now)()
|
|
const stdout = await this.outputs.create(
|
|
context.conversationId, MAXIMUM_OUTPUT_BYTES
|
|
)
|
|
let stderr: PagedOutputWriter | undefined
|
|
try {
|
|
stderr = await this.outputs.create(
|
|
context.conversationId, MAXIMUM_OUTPUT_BYTES
|
|
)
|
|
signal.throwIfAborted()
|
|
let child: DirectModelProcessChild
|
|
try {
|
|
child = (
|
|
this.options.spawnProcess ??
|
|
(spawn as unknown as DirectModelProcessSpawn)
|
|
)(input.executable, input.args, {
|
|
cwd: workingDirectory.canonicalPath,
|
|
detached: platform !== 'win32',
|
|
env: environment,
|
|
shell: false,
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
windowsHide: true
|
|
})
|
|
} catch (error) {
|
|
throw new Error('无法启动命令 Shell', { cause: error })
|
|
}
|
|
|
|
return await this.waitForProcess(
|
|
child,
|
|
shell,
|
|
workingDirectory.displayPath,
|
|
input.timeoutMs,
|
|
signal,
|
|
startedAt,
|
|
stdout,
|
|
stderr
|
|
)
|
|
} catch (error) {
|
|
await Promise.all([stdout.abort(), stderr?.abort()])
|
|
throw error
|
|
}
|
|
}
|
|
|
|
private waitForProcess(
|
|
child: DirectModelProcessChild,
|
|
shell: ProcessShellSummary,
|
|
cwd: string,
|
|
timeoutMs: number | undefined,
|
|
cancellationSignal: AbortSignal,
|
|
startedAt: number,
|
|
stdout: PagedOutputWriter,
|
|
stderr: PagedOutputWriter
|
|
): Promise<ProcessExecuteResult> {
|
|
child.stdout?.on('data', (chunk) => stdout.append(chunk))
|
|
child.stderr?.on('data', (chunk) => stderr.append(chunk))
|
|
|
|
return new Promise((resolveResult, rejectResult) => {
|
|
let settled = false
|
|
let cleanupStarted = false
|
|
let failure: Error | undefined
|
|
let terminationReason: 'timeout' | 'cancelled' | undefined
|
|
let exitCode: number | null = null
|
|
let exitSignal: string | undefined
|
|
|
|
const removeListeners = (): void => {
|
|
clearTimeout(timeout)
|
|
cancellationSignal.removeEventListener('abort', cancel)
|
|
child.stdout?.removeListener?.('error', failFromOutput)
|
|
child.stderr?.removeListener?.('error', failFromOutput)
|
|
}
|
|
const result = async (): Promise<ProcessExecuteResult> => {
|
|
const stdoutResult = await stdout.finish()
|
|
const stderrResult = await stderr.finish()
|
|
return {
|
|
shell: { kind: shell.kind, label: shell.label },
|
|
cwd,
|
|
exitCode,
|
|
...(exitSignal ? { signal: exitSignal } : {}),
|
|
...(terminationReason ? { terminationReason } : {}),
|
|
durationMs: Math.max(
|
|
0,
|
|
(this.options.now ?? Date.now)() - startedAt
|
|
),
|
|
stdout: stdoutResult.text,
|
|
stderr: stderrResult.text,
|
|
stdoutTruncated: stdoutResult.truncated,
|
|
stderrTruncated: stderrResult.truncated,
|
|
...(stdoutResult.reference ? { stdoutReference: stdoutResult.reference } : {}),
|
|
...(stderrResult.reference ? { stderrReference: stderrResult.reference } : {})
|
|
}
|
|
}
|
|
const finishResult = (): void => {
|
|
if (settled) {
|
|
return
|
|
}
|
|
settled = true
|
|
removeListeners()
|
|
resolveResult(result())
|
|
}
|
|
const finishFailure = (error: Error): void => {
|
|
if (settled) {
|
|
return
|
|
}
|
|
settled = true
|
|
removeListeners()
|
|
rejectResult(error)
|
|
}
|
|
const finishAfterCleanup = (): void => {
|
|
exitCode = child.exitCode
|
|
exitSignal = child.signalCode ?? exitSignal
|
|
if (terminationReason) {
|
|
finishResult()
|
|
} else {
|
|
finishFailure(failure ?? new Error('命令进程异常终止'))
|
|
}
|
|
}
|
|
const startCleanup = (): void => {
|
|
if (cleanupStarted || settled) {
|
|
return
|
|
}
|
|
cleanupStarted = true
|
|
clearTimeout(timeout)
|
|
void (
|
|
this.options.terminateProcessTree ??
|
|
terminateProcessTreeAndWait
|
|
)(child, {
|
|
platform: this.options.platform ?? process.platform,
|
|
processGroup:
|
|
(this.options.platform ?? process.platform) !== 'win32',
|
|
signal: 'SIGKILL',
|
|
waitMs:
|
|
this.options.terminationWaitMs ?? TERMINATION_WAIT_MS
|
|
}).then(
|
|
finishAfterCleanup,
|
|
(error: unknown) => {
|
|
if (!failure && error instanceof Error) {
|
|
failure = error
|
|
}
|
|
finishAfterCleanup()
|
|
}
|
|
)
|
|
}
|
|
const cancel = (): void => {
|
|
if (settled) {
|
|
return
|
|
}
|
|
terminationReason = 'cancelled'
|
|
startCleanup()
|
|
}
|
|
const failFromOutput = (error: Error): void => {
|
|
if (settled || terminationReason) {
|
|
return
|
|
}
|
|
failure = error
|
|
startCleanup()
|
|
}
|
|
const timeout = timeoutMs === undefined ? undefined : setTimeout(() => {
|
|
if (settled || terminationReason === 'cancelled') {
|
|
return
|
|
}
|
|
terminationReason = 'timeout'
|
|
startCleanup()
|
|
}, timeoutMs)
|
|
timeout?.unref?.()
|
|
|
|
child.stdout?.once('error', failFromOutput)
|
|
child.stderr?.once('error', failFromOutput)
|
|
child.once('error', (error: unknown) => {
|
|
if (settled || terminationReason) {
|
|
return
|
|
}
|
|
failure =
|
|
error instanceof Error
|
|
? error
|
|
: new Error('命令进程发生未知错误')
|
|
startCleanup()
|
|
})
|
|
child.once('close', (...values: unknown[]) => {
|
|
const [code, processSignal] = values
|
|
exitCode = typeof code === 'number' ? code : child.exitCode
|
|
exitSignal =
|
|
typeof processSignal === 'string'
|
|
? processSignal
|
|
: child.signalCode ?? undefined
|
|
if (cleanupStarted) {
|
|
return
|
|
}
|
|
finishResult()
|
|
})
|
|
cancellationSignal.addEventListener('abort', cancel, {
|
|
once: true
|
|
})
|
|
if (cancellationSignal.aborted) {
|
|
cancel()
|
|
}
|
|
})
|
|
}
|
|
|
|
async releaseConversation(conversationId: string): Promise<void> {
|
|
const calls = [...(this.activeCalls.get(conversationId) ?? [])]
|
|
for (const call of calls) {
|
|
call.controller.abort(abortError('对话进程资源已释放'))
|
|
}
|
|
await Promise.all(calls.map((call) => call.done))
|
|
await this.outputs.releaseOwner(conversationId)
|
|
}
|
|
|
|
readOutput(ownerId: string, handle: string, cursor?: number, limitBytes?: number): Promise<PagedOutputPage> {
|
|
return this.outputs.read(ownerId, handle, cursor, limitBytes)
|
|
}
|
|
|
|
dispose(): Promise<void> {
|
|
this.disposePromise ??= this.disposeOnce()
|
|
return this.disposePromise
|
|
}
|
|
|
|
private async disposeOnce(): Promise<void> {
|
|
this.disposed = true
|
|
const calls = [...this.activeCalls.values()].flatMap((set) => [
|
|
...set
|
|
])
|
|
for (const call of calls) {
|
|
call.controller.abort(abortError('进程执行服务已关闭'))
|
|
}
|
|
await Promise.all(calls.map((call) => call.done))
|
|
await this.outputs.dispose()
|
|
}
|
|
}
|