
























@@ -12,8 +12,12 @@ export interface GuestExecOptions {
1212export interface WindowsBackgroundPowerShellOptions {
1313append?: (chunk: string | Uint8Array) => void;
1414beforeLaunchAttempt?: () => void;
15+completedLogDrainGraceMs?: number;
1516label: string;
17+logChunkBytes?: number;
1618onLaunchRetry?: (message: string) => void;
19+pollIntervalMs?: number;
20+runCommand?: typeof run;
1721script: string;
1822timeoutMs: number;
1923vmName: string;
@@ -52,14 +56,22 @@ export async function runWindowsBackgroundPowerShell(
5256options: WindowsBackgroundPowerShellOptions,
5357): Promise<void> {
5458const append = options.append;
59+const completedLogDrainGraceMs = Math.max(
60+1,
61+Math.floor(options.completedLogDrainGraceMs ?? 30_000),
62+);
63+const logChunkBytes = Math.max(1, Math.floor(options.logChunkBytes ?? 1024 * 1024));
64+const pollIntervalMs = Math.max(1, Math.floor(options.pollIntervalMs ?? 5_000));
65+const runCommand = options.runCommand ?? run;
5566const safeLabel = options.label.replaceAll(/[^A-Za-z0-9_-]/g, "-");
5667const nonce = `${safeLabel}-${Date.now()}-${Math.floor(Math.random() * 100000)}`;
5768const fileBase = `openclaw-parallels-${nonce}`;
5869const pathsScript = `$base = Join-Path $env:TEMP ${psSingleQuote(fileBase)}
5970$scriptPath = "$base.ps1"
6071$logPath = "$base.log"
6172$donePath = "$base.done"
62-$exitPath = "$base.exit"`;
73+$exitPath = "$base.exit"
74+$pidPath = "$base.pid"`;
6375const payload = `$ErrorActionPreference = 'Stop'
6476$PSNativeCommandUseErrorActionPreference = $false
6577${pathsScript}
@@ -74,7 +86,7 @@ ${options.script}
7486} finally {
7587 Set-Content -Path $donePath -Value 'done' -Encoding UTF8
7688}`;
77-const writeScript = run(
89+const writeScript = runCommand(
7890"prlctl",
7991[
8092"exec",
@@ -86,7 +98,7 @@ ${options.script}
8698"Bypass",
8799"-EncodedCommand",
88100encodePowerShell(`${pathsScript}
89-Remove-Item -Path $scriptPath, $logPath, $donePath, $exitPath -Force -ErrorAction SilentlyContinue
101+Remove-Item -Path $scriptPath, $logPath, $donePath, $exitPath, $pidPath -Force -ErrorAction SilentlyContinue
90102[System.IO.File]::WriteAllText($scriptPath, [Console]::In.ReadToEnd(), [System.Text.UTF8Encoding]::new($false))
91103if (!(Test-Path $scriptPath)) { throw "${safeLabel} background script was not written" }`),
92104],
@@ -99,81 +111,102 @@ if (!(Test-Path $scriptPath)) { throw "${safeLabel} background script was not wr
99111);
100112}
101113102-const deadline = Date.now() + options.timeoutMs;
103-let launched = false;
104-let lastLaunchStatus = 0;
105-for (let attempt = 1; attempt <= 5 && Date.now() < deadline; attempt++) {
106-options.beforeLaunchAttempt?.();
107-const launch = run(
108-"prlctl",
109-[
110-"exec",
111-options.vmName,
112-"--current-user",
113-"powershell.exe",
114-"-NoProfile",
115-"-ExecutionPolicy",
116-"Bypass",
117-"-EncodedCommand",
118-encodePowerShell(`${pathsScript}
119-Start-Process -FilePath powershell.exe -WindowStyle Hidden -ArgumentList @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $scriptPath)
114+let doneSeen = false;
115+try {
116+const deadline = Date.now() + options.timeoutMs;
117+let launched = false;
118+let lastLaunchStatus = 0;
119+for (let attempt = 1; attempt <= 5 && Date.now() < deadline; attempt++) {
120+options.beforeLaunchAttempt?.();
121+const launch = runCommand(
122+"prlctl",
123+[
124+"exec",
125+options.vmName,
126+"--current-user",
127+"powershell.exe",
128+"-NoProfile",
129+"-ExecutionPolicy",
130+"Bypass",
131+"-EncodedCommand",
132+encodePowerShell(`${pathsScript}
133+$process = Start-Process -FilePath powershell.exe -WindowStyle Hidden -ArgumentList @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $scriptPath) -PassThru
134+Set-Content -Path $pidPath -Value $process.Id -Encoding UTF8
120135'started'`),
121-],
122-{ check: false, quiet: true, timeoutMs: timeoutBefore(deadline, 30_000) },
123-);
124-appendOutput(append, launch);
125-if (launch.status === 0 && launch.stdout.includes("started")) {
126-launched = true;
127-break;
128-}
129-lastLaunchStatus = launch.status;
130-if (launch.status === 0 || launch.status === 124) {
131-const materialized = waitForWindowsBackgroundMaterialized({
132- append,
133- deadline,
134- pathsScript,
135-vmName: options.vmName,
136-});
137-if (materialized) {
136+],
137+{ check: false, quiet: true, timeoutMs: timeoutBefore(deadline, 30_000) },
138+);
139+appendOutput(append, launch);
140+if (launch.status === 0 && launch.stdout.includes("started")) {
138141launched = true;
139142break;
140143}
141-options.onLaunchRetry?.(
142-`${options.label} launch retry ${attempt}: background log/done file did not materialize`,
143-);
144-continue;
144+lastLaunchStatus = launch.status;
145+if (launch.status === 0 || launch.status === 124) {
146+const materialized = waitForWindowsBackgroundMaterialized({
147+ append,
148+ deadline,
149+ pathsScript,
150+ runCommand,
151+vmName: options.vmName,
152+});
153+if (materialized) {
154+launched = true;
155+break;
156+}
157+options.onLaunchRetry?.(
158+`${options.label} launch retry ${attempt}: background log/done file did not materialize`,
159+);
160+continue;
161+}
162+if (launch.stdout.includes("restoring") || launch.stderr.includes("restoring")) {
163+options.onLaunchRetry?.(`${options.label} launch retry ${attempt}: VM is still restoring`);
164+await sleep(5_000);
165+continue;
166+}
167+throw new Error(`${options.label} background launch failed with exit code ${launch.status}`);
145168}
146-if (launch.stdout.includes("restoring") || launch.stderr.includes("restoring")) {
147-options.onLaunchRetry?.(`${options.label} launch retry ${attempt}: VM is still restoring`);
148-await sleep(5_000);
149-continue;
169+if (!launched) {
170+throw new Error(
171+ `${options.label} background launch failed with exit code ${lastLaunchStatus}`,
172+);
150173}
151-throw new Error(`${options.label} background launch failed with exit code ${launch.status}`);
152-}
153-if (!launched) {
154-throw new Error(`${options.label} background launch failed with exit code ${lastLaunchStatus}`);
155-}
156174157-let lastLogOffset = 0;
158-while (Date.now() < deadline) {
159-const poll = run(
160-"prlctl",
161-[
162-"exec",
163-options.vmName,
164-"--current-user",
165-"powershell.exe",
166-"-NoProfile",
167-"-ExecutionPolicy",
168-"Bypass",
169-"-EncodedCommand",
170-encodePowerShell(`${pathsScript}
175+let lastLogOffset = 0;
176+let completedLogDrainDeadline = 0;
177+const activeDeadline = () => (doneSeen ? completedLogDrainDeadline : deadline);
178+while (Date.now() < activeDeadline()) {
179+const poll = runCommand(
180+"prlctl",
181+[
182+"exec",
183+options.vmName,
184+"--current-user",
185+"powershell.exe",
186+"-NoProfile",
187+"-ExecutionPolicy",
188+"Bypass",
189+"-EncodedCommand",
190+encodePowerShell(`${pathsScript}
171191$offset = ${lastLogOffset}
172192if (Test-Path $logPath) {
173- $bytes = [System.IO.File]::ReadAllBytes($logPath)
174- if ($bytes.Length -gt $offset) {
175- "__OPENCLAW_LOG_OFFSET__:$($bytes.Length)"
176- [System.Text.Encoding]::UTF8.GetString($bytes, $offset, $bytes.Length - $offset)
193+ $stream = [System.IO.File]::Open($logPath, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::ReadWrite)
194+ try {
195+ $length = $stream.Length
196+ "__OPENCLAW_LOG_LENGTH__:$length"
197+ if ($length -gt $offset) {
198+ [void]$stream.Seek($offset, [System.IO.SeekOrigin]::Begin)
199+ $count = [int][Math]::Min($length - $offset, ${logChunkBytes})
200+ $buffer = New-Object byte[] $count
201+ $read = $stream.Read($buffer, 0, $count)
202+ if ($read -gt 0) {
203+ $nextOffset = $offset + $read
204+ "__OPENCLAW_LOG_OFFSET__:$nextOffset"
205+ [System.Text.Encoding]::UTF8.GetString($buffer, 0, $read)
206+ }
207+ }
208+ } finally {
209+ $stream.Dispose()
177210 }
178211}
179212if (Test-Path $donePath) {
@@ -183,37 +216,53 @@ if (Test-Path $donePath) {
183216 if ($backgroundExit -ne '0') { exit 23 }
184217 exit 0
185218}`),
186-],
187-{ check: false, quiet: true, timeoutMs: timeoutBefore(deadline, 30_000) },
188-);
189-appendOutput(append, poll);
190-const offsetMatch = poll.stdout.match(/__OPENCLAW_LOG_OFFSET__:(\d+)/);
191-if (offsetMatch) {
192-lastLogOffset = Number(offsetMatch[1]);
193-}
194-if (poll.stdout.includes("__OPENCLAW_BACKGROUND_DONE__")) {
195-const exitMatch = poll.stdout.match(/__OPENCLAW_BACKGROUND_EXIT__:(\S+)/);
196-const backgroundExit = exitMatch?.[1] ?? "0";
197-if (backgroundExit !== "0" || (poll.status !== 0 && poll.status !== 124)) {
198-throw new Error(`${options.label} failed`);
219+],
220+{ check: false, quiet: true, timeoutMs: timeoutBefore(deadline, 30_000) },
221+);
222+appendOutput(append, poll);
223+const offsetMatch = poll.stdout.match(/__OPENCLAW_LOG_OFFSET__:(\d+)/);
224+if (offsetMatch) {
225+lastLogOffset = Number(offsetMatch[1]);
199226}
200-cleanupWindowsBackground(options.vmName, pathsScript);
201-return;
227+const lengthMatch = poll.stdout.match(/__OPENCLAW_LOG_LENGTH__:(\d+)/);
228+const logLength = lengthMatch ? Number(lengthMatch[1]) : lastLogOffset;
229+if (poll.stdout.includes("__OPENCLAW_BACKGROUND_DONE__")) {
230+doneSeen = true;
231+completedLogDrainDeadline ||= Date.now() + completedLogDrainGraceMs;
232+if (lastLogOffset < logLength) {
233+await sleep(Math.min(pollIntervalMs, 100));
234+continue;
235+}
236+const exitMatch = poll.stdout.match(/__OPENCLAW_BACKGROUND_EXIT__:(\S+)/);
237+const backgroundExit = exitMatch?.[1] ?? "0";
238+if (backgroundExit !== "0" || (poll.status !== 0 && poll.status !== 124)) {
239+throw new Error(`${options.label} failed`);
240+}
241+return;
242+}
243+await sleep(pollIntervalMs);
244+}
245+if (doneSeen) {
246+throw new Error(`${options.label} completed but log drain timed out`);
202247}
203-await sleep(5_000);
248+throw new Error(`${options.label} timed out`);
249+} finally {
250+cleanupWindowsBackground(options.vmName, pathsScript, runCommand, {
251+stopProcessTree: !doneSeen,
252+});
204253}
205-throw new Error(`${options.label} timed out`);
206254}
207255208256function waitForWindowsBackgroundMaterialized(params: {
209257append?: (chunk: string | Uint8Array) => void;
210258deadline: number;
211259pathsScript: string;
260+runCommand: typeof run;
212261vmName: string;
213262}): boolean {
214263const materializeDeadline = Math.min(Date.now() + 45_000, params.deadline);
215264while (Date.now() < materializeDeadline) {
216-const result = run(
265+const result = params.runCommand(
217266"prlctl",
218267[
219268"exec",
@@ -239,8 +288,28 @@ if ((Test-Path $logPath) -or (Test-Path $donePath)) {
239288return false;
240289}
241290242-function cleanupWindowsBackground(vmName: string, pathsScript: string): void {
243-run(
291+function cleanupWindowsBackground(
292+vmName: string,
293+pathsScript: string,
294+runCommand: typeof run,
295+options: { stopProcessTree: boolean },
296+): void {
297+const stopProcessTree = options.stopProcessTree
298+ ? `function Stop-OpenClawBackgroundProcessTree([int]$ProcessId) {
299+ Get-CimInstance Win32_Process -Filter "ParentProcessId=$ProcessId" -ErrorAction SilentlyContinue | ForEach-Object {
300+ Stop-OpenClawBackgroundProcessTree ([int]$_.ProcessId)
301+ }
302+ Stop-Process -Id $ProcessId -Force -ErrorAction SilentlyContinue
303+}
304+if (Test-Path $pidPath) {
305+ $backgroundPid = (Get-Content -Path $pidPath -Raw).Trim()
306+ if ($backgroundPid) {
307+ Stop-OpenClawBackgroundProcessTree ([int]$backgroundPid)
308+ }
309+}
310+`
311+ : "";
312+runCommand(
244313"prlctl",
245314[
246315"exec",
@@ -252,7 +321,8 @@ function cleanupWindowsBackground(vmName: string, pathsScript: string): void {
252321"Bypass",
253322"-EncodedCommand",
254323encodePowerShell(`${pathsScript}
255-Remove-Item -Path $scriptPath, $logPath, $donePath, $exitPath -Force -ErrorAction SilentlyContinue`),
324+${stopProcessTree}
325+Remove-Item -Path $scriptPath, $logPath, $donePath, $exitPath, $pidPath -Force -ErrorAction SilentlyContinue`),
256326],
257327{ check: false, quiet: true, timeoutMs: 30_000 },
258328);
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。