fix(parallels): stabilize Windows beta smoke transport

This commit is contained in:
Vincent Koc 2026-06-28 11:47:06 -07:00
parent 30109542d3
commit 0a4d0daa8c
No known key found for this signature in database
5 changed files with 220 additions and 165 deletions

View file

@ -16,7 +16,6 @@ export interface WindowsBackgroundPowerShellOptions {
beforeLaunchAttempt?: () => void;
completedLogDrainGraceMs?: number;
label: string;
logChunkBytes?: number;
onLaunchRetry?: (message: string) => void;
pollIntervalMs?: number;
runCommand?: typeof run;
@ -59,6 +58,7 @@ function throwIfFailed(label: string, result: CommandResult, check: boolean | un
}
const POSIX_GUEST_SCRIPT_CLEANUP_TIMEOUT_MS = 30_000;
const WINDOWS_BACKGROUND_LOG_MAX_BYTES = 8 * 1024 * 1024;
function appendCommandResult(phases: PhaseRunner, result: CommandResult): void {
phases.append(result.stdout);
@ -88,36 +88,54 @@ export async function runWindowsBackgroundPowerShell(
1,
Math.floor(options.completedLogDrainGraceMs ?? 30_000),
);
const logChunkBytes = Math.max(1, Math.floor(options.logChunkBytes ?? 1024 * 1024));
const pollIntervalMs = Math.max(1, Math.floor(options.pollIntervalMs ?? 5_000));
const runCommand = options.runCommand ?? run;
const safeLabel = options.label.replaceAll(/[^A-Za-z0-9_-]/g, "-");
const nonce = `${safeLabel}-${randomUUID()}`;
const fileBase = `openclaw-parallels-${nonce}`;
const logLengthPrefix = `__OPENCLAW_LOG_LENGTH__:${nonce}:`;
const logOffsetPrefix = `__OPENCLAW_LOG_OFFSET__:${nonce}:`;
const guestRunDir = `openclaw-parallels\\${nonce}`;
const windowsDonePath = `%WINDIR%\\Temp\\${guestRunDir}\\done`;
const windowsLogPath = `%WINDIR%\\Temp\\${guestRunDir}\\run.log`;
const backgroundExitPrefix = `__OPENCLAW_BACKGROUND_EXIT__:${nonce}:`;
const backgroundDoneMarker = `__OPENCLAW_BACKGROUND_DONE__:${nonce}`;
const pathsScript = `$base = Join-Path $env:TEMP ${psSingleQuote(fileBase)}
$scriptPath = "$base.ps1"
$logPath = "$base.log"
$donePath = "$base.done"
$exitPath = "$base.exit"
$pidPath = "$base.pid"
const deadline = Date.now() + options.timeoutMs;
const pathsScript = `$runDir = Join-Path (Join-Path $env:WINDIR 'Temp\\openclaw-parallels') ${psSingleQuote(nonce)}
$scriptPath = Join-Path $runDir 'run.ps1'
$logPath = Join-Path $runDir 'run.log'
$donePath = Join-Path $runDir 'done'
$exitPath = Join-Path $runDir 'exit'
$pidPath = Join-Path $runDir 'pid'
function Write-OpenClawUtf8File([string]$Path, [string]$Value) {
[System.IO.File]::WriteAllText($Path, $Value, [System.Text.UTF8Encoding]::new($false))
}`;
const payload = `$ErrorActionPreference = 'Stop'
$PSNativeCommandUseErrorActionPreference = $false
${pathsScript}
Write-OpenClawUtf8File $pidPath ([string]$PID)
$script:OpenClawBackgroundLogBytes = 0
function Add-OpenClawBackgroundLog {
param([Parameter(ValueFromPipeline=$true)]$InputObject)
process {
$text = $InputObject | Out-String
$bytes = [System.Text.Encoding]::UTF8.GetBytes($text)
$remaining = [int64]${WINDOWS_BACKGROUND_LOG_MAX_BYTES} - $script:OpenClawBackgroundLogBytes
if ($remaining -le 0) {
return
}
$count = [int][Math]::Min($remaining, $bytes.Length)
$needsBoundaryNewline = $count -eq $remaining -and $count -gt 0 -and $bytes[$count - 1] -ne 10
if ($needsBoundaryNewline) {
$count--
}
$stream = [System.IO.File]::Open($logPath, [System.IO.FileMode]::Append, [System.IO.FileAccess]::Write, [System.IO.FileShare]::ReadWrite)
try {
$stream.Write($bytes, 0, $bytes.Length)
if ($count -gt 0) {
$stream.Write($bytes, 0, $count)
$script:OpenClawBackgroundLogBytes += $count
}
if ($needsBoundaryNewline) {
$stream.WriteByte(10)
$script:OpenClawBackgroundLogBytes++
}
} finally {
$stream.Dispose()
}
@ -134,25 +152,41 @@ ${options.script}
} finally {
Write-OpenClawUtf8File $donePath 'done'
}`;
const writeScript = runCommand(
"prlctl",
[
"exec",
options.vmName,
"--current-user",
"powershell.exe",
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-EncodedCommand",
encodePowerShell(`${pathsScript}
const writeArgs = [
"exec",
options.vmName,
"--current-user",
"powershell.exe",
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-EncodedCommand",
encodePowerShell(`${pathsScript}
New-Item -ItemType Directory -Path $runDir -Force | Out-Null
& icacls.exe $runDir /inheritance:r /grant:r "\${env:USERNAME}:(OI)(CI)(F)" "SYSTEM:(OI)(CI)(F)" | Out-Null
if ($LASTEXITCODE -ne 0) { throw "${safeLabel} background directory ACL setup failed" }
Remove-Item -Path $scriptPath, $logPath, $donePath, $exitPath, $pidPath -Force -ErrorAction SilentlyContinue
[System.IO.File]::WriteAllText($scriptPath, [Console]::In.ReadToEnd(), [System.Text.UTF8Encoding]::new($false))
if (!(Test-Path $scriptPath)) { throw "${safeLabel} background script was not written" }`),
],
{ check: false, input: payload, timeoutMs: Math.min(options.timeoutMs, 120_000) },
);
];
let writeScript = runCommand("prlctl", writeArgs, {
check: false,
input: payload,
timeoutMs: timeoutBefore(deadline, 120_000),
});
appendOutput(append, writeScript);
if (writeScript.status === 255) {
options.onLaunchRetry?.(
`${options.label} background script write retry after guest transport rc255`,
);
options.beforeLaunchAttempt?.();
writeScript = runCommand("prlctl", writeArgs, {
check: false,
input: payload,
timeoutMs: timeoutBefore(deadline, 120_000),
});
appendOutput(append, writeScript);
}
if (writeScript.status !== 0) {
throw new Error(
`${options.label} background script write failed with exit code ${writeScript.status}`,
@ -161,7 +195,6 @@ if (!(Test-Path $scriptPath)) { throw "${safeLabel} background script was not wr
let doneSeen = false;
try {
const deadline = Date.now() + options.timeoutMs;
let launched = false;
let lastLaunchStatus = 0;
for (let attempt = 1; attempt <= 5 && Date.now() < deadline; attempt++) {
@ -178,11 +211,13 @@ if (!(Test-Path $scriptPath)) { throw "${safeLabel} background script was not wr
"Bypass",
"-EncodedCommand",
encodePowerShell(`${pathsScript}
$process = Start-Process -FilePath powershell.exe -WindowStyle Hidden -ArgumentList @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $scriptPath) -PassThru
Write-OpenClawUtf8File $pidPath ([string]$process.Id)
cmd.exe /d /s /c start "" /b powershell.exe -NoProfile -ExecutionPolicy Bypass -File "$scriptPath" | Out-Null
'started'`),
],
{ check: false, quiet: true, timeoutMs: timeoutBefore(deadline, 30_000) },
// A busy Windows guest can leave one Parallels Tools session wedged.
// Keep polls short so a single transport cancellation cannot consume
// the entire install timeout while the detached process continues.
{ check: false, quiet: true, timeoutMs: timeoutBefore(deadline, 8_000) },
);
appendOutput(append, launch);
if (launch.status === 0 && launch.stdout.includes("started")) {
@ -221,81 +256,64 @@ Write-OpenClawUtf8File $pidPath ([string]$process.Id)
);
}
let lastLogOffset = 0;
let completedLogDrainDeadline = 0;
const activeDeadline = () => (doneSeen ? completedLogDrainDeadline : deadline);
let doneFileSeen = false;
const activeDeadline = () => (doneFileSeen ? completedLogDrainDeadline : deadline);
while (Date.now() < activeDeadline()) {
const doneProbe = runCommand(
"prlctl",
[
"exec",
options.vmName,
"cmd.exe",
"/d",
"/s",
"/c",
`if exist "${windowsDonePath}" (echo done) else (echo wait)`,
],
{ check: false, quiet: true, timeoutMs: timeoutBefore(deadline, 5_000) },
);
appendOutput(append, doneProbe);
if (doneProbe.stdout.split(/\r?\n/u).some((line) => line.trim() === "done")) {
doneFileSeen = true;
completedLogDrainDeadline ||= Date.now() + completedLogDrainGraceMs;
} else {
await sleep(pollIntervalMs);
continue;
}
const poll = runCommand(
"prlctl",
[
"exec",
options.vmName,
"--current-user",
"powershell.exe",
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-EncodedCommand",
encodePowerShell(`${pathsScript}
$offset = ${lastLogOffset}
if (Test-Path $logPath) {
$stream = [System.IO.File]::Open($logPath, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::ReadWrite)
try {
$length = $stream.Length
${psSingleQuote(logLengthPrefix)} + $length
if ($length -gt $offset) {
[void]$stream.Seek($offset, [System.IO.SeekOrigin]::Begin)
$count = [int][Math]::Min($length - $offset, ${logChunkBytes})
$buffer = New-Object byte[] $count
$read = $stream.Read($buffer, 0, $count)
if ($read -gt 0) {
$nextOffset = $offset + $read
${psSingleQuote(logOffsetPrefix)} + $nextOffset
[System.Text.Encoding]::UTF8.GetString($buffer, 0, $read)
}
}
} finally {
$stream.Dispose()
}
}
if (Test-Path $donePath) {
$backgroundExit = if (Test-Path $exitPath) { (Get-Content -Path $exitPath -Raw).Trim() } else { '0' }
${psSingleQuote(backgroundExitPrefix)} + $backgroundExit
${psSingleQuote(backgroundDoneMarker)}
if ($backgroundExit -ne '0') { exit 23 }
exit 0
}`),
"cmd.exe",
"/d",
"/s",
"/c",
`if exist "${windowsDonePath}" (type "%WINDIR%\\Temp\\${guestRunDir}\\run.log" & for /f "usebackq delims=" %A in ("%WINDIR%\\Temp\\${guestRunDir}\\exit") do @echo ${backgroundExitPrefix}%A & echo ${backgroundDoneMarker}) else (echo wait)`,
],
{ check: false, quiet: true, timeoutMs: timeoutBefore(deadline, 30_000) },
{ check: false, quiet: true, timeoutMs: timeoutBefore(activeDeadline(), 30_000) },
);
appendOutput(append, poll);
const offsetRaw = findControlValue(poll.stdout, logOffsetPrefix);
if (offsetRaw) {
lastLogOffset = Number(offsetRaw);
}
const lengthRaw = findControlValue(poll.stdout, logLengthPrefix);
const logLength = lengthRaw ? Number(lengthRaw) : lastLogOffset;
if (hasControlLine(poll.stdout, backgroundDoneMarker)) {
doneSeen = true;
completedLogDrainDeadline ||= Date.now() + completedLogDrainGraceMs;
if (lastLogOffset < logLength) {
await sleep(Math.min(pollIntervalMs, 100));
continue;
}
const backgroundExit = findControlValue(poll.stdout, backgroundExitPrefix) ?? "0";
if (backgroundExit !== "0" || (poll.status !== 0 && poll.status !== 124)) {
throw new Error(`${options.label} failed`);
}
return;
}
await sleep(pollIntervalMs);
await sleep(Math.min(pollIntervalMs, 100));
}
if (doneSeen) {
throw new Error(`${options.label} completed but log drain timed out`);
}
throw new Error(`${options.label} timed out`);
} finally {
cleanupWindowsBackground(options.vmName, pathsScript, runCommand, {
cleanupWindowsBackground(options.vmName, pathsScript, windowsLogPath, runCommand, {
append,
captureLog: !doneSeen,
stopProcessTree: !doneSeen,
});
}
@ -325,14 +343,13 @@ async function waitForWindowsBackgroundMaterialized(params: {
[
"exec",
params.vmName,
"--current-user",
"powershell.exe",
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-EncodedCommand",
encodePowerShell(`${params.pathsScript}
if ((Test-Path $logPath) -or (Test-Path $donePath)) {
if ((Test-Path $pidPath) -or (Test-Path $donePath)) {
'materialized'
}`),
],
@ -350,8 +367,13 @@ if ((Test-Path $logPath) -or (Test-Path $donePath)) {
function cleanupWindowsBackground(
vmName: string,
pathsScript: string,
windowsLogPath: string,
runCommand: typeof run,
options: { stopProcessTree: boolean },
options: {
append?: (chunk: string | Uint8Array) => void;
captureLog: boolean;
stopProcessTree: boolean;
},
): void {
const stopProcessTree = options.stopProcessTree
? `function Stop-OpenClawBackgroundProcessTree([int]$ProcessId) {
@ -373,15 +395,45 @@ if (Test-Path $pidPath) {
[
"exec",
vmName,
"--current-user",
"powershell.exe",
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-EncodedCommand",
encodePowerShell(`${pathsScript}
${stopProcessTree}
Remove-Item -Path $scriptPath, $logPath, $donePath, $exitPath, $pidPath -Force -ErrorAction SilentlyContinue`),
${stopProcessTree}`),
],
{ check: false, quiet: true, timeoutMs: 30_000 },
);
if (options.captureLog) {
const log = runCommand(
"prlctl",
[
"exec",
vmName,
"cmd.exe",
"/d",
"/s",
"/c",
`if exist "${windowsLogPath}" type "${windowsLogPath}"`,
],
{ check: false, quiet: true, timeoutMs: 30_000 },
);
appendOutput(options.append, log);
}
runCommand(
"prlctl",
[
"exec",
vmName,
"powershell.exe",
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-EncodedCommand",
encodePowerShell(`${pathsScript}
Remove-Item -Path $scriptPath, $logPath, $donePath, $exitPath, $pidPath -Force -ErrorAction SilentlyContinue
Remove-Item -Path $runDir -Recurse -Force -ErrorAction SilentlyContinue`),
],
{ check: false, quiet: true, timeoutMs: 30_000 },
);

View file

@ -1163,6 +1163,7 @@ export class NpmUpdateSmoke {
): Promise<void> {
await runWindowsBackgroundPowerShell({
append: (chunk) => ctx.append(chunk),
beforeLaunchAttempt: () => ensureVmRunning(windowsVm),
label: "Windows update",
script,
timeoutMs,

View file

@ -94,6 +94,9 @@ interface WindowsSummary {
};
}
const WINDOWS_PACKAGE_INSTALL_TIMEOUT_SECONDS = 900;
const WINDOWS_PACKAGE_INSTALL_TIMEOUT_MS = WINDOWS_PACKAGE_INSTALL_TIMEOUT_SECONDS * 1000;
const defaultOptions = (): WindowsOptions => ({
hostIp: undefined,
hostPort: 18426,
@ -362,7 +365,9 @@ class WindowsSmoke extends SmokeRunController<WindowsOptions> {
ensureGuestGit({ guest: this.guest, minGitZipPath: this.minGitZipPath, server: this.server }),
);
await this.phase("fresh.preflight", 120, () => this.logGuestPreflight(true));
await this.phase("fresh.install-main", 420, () => this.installMain("openclaw-main-fresh.tgz"));
await this.phase("fresh.install-main", WINDOWS_PACKAGE_INSTALL_TIMEOUT_SECONDS, () =>
this.installMain("openclaw-main-fresh.tgz"),
);
this.status.freshVersion = await this.extractLastVersion("fresh.install-main");
await this.phase("fresh.verify-main-version", 120, () => this.verifyTargetVersion());
await this.phase("fresh.onboard-ref", 720, () => this.runRefOnboard());
@ -381,8 +386,10 @@ class WindowsSmoke extends SmokeRunController<WindowsOptions> {
);
await this.phase("upgrade.preflight", 120, () => this.logGuestPreflight(false));
if (this.options.targetPackageSpec || this.options.upgradeFromPackedMain) {
await this.phase("upgrade.install-baseline-package", 420, () =>
this.installMain("openclaw-main-upgrade.tgz"),
await this.phase(
"upgrade.install-baseline-package",
WINDOWS_PACKAGE_INSTALL_TIMEOUT_SECONDS,
() => this.installMain("openclaw-main-upgrade.tgz"),
);
this.status.latestInstalledVersion = await this.extractLastVersion(
"upgrade.install-baseline-package",
@ -391,7 +398,9 @@ class WindowsSmoke extends SmokeRunController<WindowsOptions> {
this.verifyTargetVersion(),
);
} else {
await this.phase("upgrade.install-baseline", 420, () => this.installLatestRelease());
await this.phase("upgrade.install-baseline", WINDOWS_PACKAGE_INSTALL_TIMEOUT_SECONDS, () =>
this.installLatestRelease(),
);
this.status.latestInstalledVersion = await this.extractLastVersion(
"upgrade.install-baseline",
);
@ -538,33 +547,37 @@ ${cleanScript}`,
);
}
private installLatestRelease(): void {
private installLatestRelease(): Promise<void> {
const versionArg = this.installVersion ? ` -Tag ${psSingleQuote(this.installVersion)}` : "";
this.guestPowerShell(
return this.guestPowerShellBackground(
"install-latest",
`$ErrorActionPreference = 'Stop'
$script = Invoke-RestMethod -Uri ${psSingleQuote(this.options.installUrl)} -TimeoutSec 120
& ([scriptblock]::Create($script))${versionArg} -NoOnboard
if ($LASTEXITCODE -ne 0) { throw "installer failed with exit code $LASTEXITCODE" }
Invoke-OpenClaw --version
if ($LASTEXITCODE -ne 0) { throw "openclaw --version failed with exit code $LASTEXITCODE" }`,
{ timeoutMs: 420_000 },
if ($LASTEXITCODE -ne 0) { throw "openclaw --version failed with exit code $LASTEXITCODE" }`,
this.remainingPhaseTimeoutMs(WINDOWS_PACKAGE_INSTALL_TIMEOUT_MS) ??
WINDOWS_PACKAGE_INSTALL_TIMEOUT_MS,
);
}
private installMain(tempName: string): void {
private installMain(tempName: string): Promise<void> {
if (!this.artifact || !this.server) {
die("package artifact/server missing");
}
const tgzUrl = this.server.urlFor(this.artifact.path);
this.guestPowerShell(
return this.guestPowerShellBackground(
`install-main-${tempName.replaceAll(/[^A-Za-z0-9_-]/g, "-")}`,
`$ErrorActionPreference = 'Stop'
$tgz = Join-Path $env:TEMP ${psSingleQuote(tempName)}
curl.exe -fsSL --connect-timeout 10 --max-time 120 --retry 2 --retry-delay 2 ${psSingleQuote(tgzUrl)} -o $tgz
npm.cmd install -g $tgz --no-fund --no-audit --loglevel=error
if ($LASTEXITCODE -ne 0) { throw "npm install failed with exit code $LASTEXITCODE" }
Invoke-OpenClaw --version
if ($LASTEXITCODE -ne 0) { throw "openclaw --version failed with exit code $LASTEXITCODE" }`,
{ timeoutMs: 420_000 },
if ($LASTEXITCODE -ne 0) { throw "openclaw --version failed with exit code $LASTEXITCODE" }`,
this.remainingPhaseTimeoutMs(WINDOWS_PACKAGE_INSTALL_TIMEOUT_MS) ??
WINDOWS_PACKAGE_INSTALL_TIMEOUT_MS,
);
}
@ -622,11 +635,14 @@ ${this.windowsPluginIsolationScript()}`,
await runWindowsBackgroundPowerShell({
append: (chunk) =>
this.log(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8")),
beforeLaunchAttempt: () => this.waitForGuestReady(120),
beforeLaunchAttempt: () => {
ensureVmRunning(this.options.vmName, 120);
this.waitForGuestReady(120);
},
label,
onLaunchRetry: warn,
script: `${windowsOpenClawResolver}\n${script}`,
timeoutMs,
timeoutMs: this.remainingPhaseTimeoutMs(timeoutMs) ?? timeoutMs,
vmName: this.options.vmName,
});
}

View file

@ -82,8 +82,6 @@ function decodePowerShellFromArgs(args: string[]): string {
function extractWindowsBackgroundControlMarkers(decoded: string): {
done: string;
exitPrefix: string;
lengthPrefix: string;
offsetPrefix: string;
} {
const marker = (name: string, trailingColon: boolean): string => {
const suffix = trailingColon ? ":" : "";
@ -96,8 +94,6 @@ function extractWindowsBackgroundControlMarkers(decoded: string): {
return {
done: marker("__OPENCLAW_BACKGROUND_DONE__", false),
exitPrefix: marker("__OPENCLAW_BACKGROUND_EXIT__", true),
lengthPrefix: marker("__OPENCLAW_LOG_LENGTH__", true),
offsetPrefix: marker("__OPENCLAW_LOG_OFFSET__", true),
};
}
@ -620,7 +616,7 @@ exit 1
expect(transports).toContain("${options.label} timed out");
});
it("cleans timed-out Windows background work and reads bounded log chunks", async () => {
it("cleans timed-out Windows background work", async () => {
const decodedCommands: string[] = [];
const inputs: string[] = [];
const fakeRun: typeof hostCommandRun = (_command, args, options) => {
@ -629,16 +625,18 @@ exit 1
if (options?.input) {
inputs.push(String(options.input));
}
if (decoded.includes("Start-Process")) {
if (decoded.includes('cmd.exe /d /s /c start "" /b powershell.exe')) {
return { status: 0, stderr: "", stdout: "started\n" };
}
if (args.includes("cmd.exe")) {
return { status: 0, stderr: "", stdout: "wait\n" };
}
return { status: 0, stderr: "", stdout: "" };
};
await expect(
runWindowsBackgroundPowerShell({
label: "windows background timeout",
logChunkBytes: 64,
pollIntervalMs: 1,
runCommand: fakeRun,
script: "Start-Sleep -Seconds 60",
@ -654,11 +652,9 @@ exit 1
expect(commands).toContain("[System.Text.UTF8Encoding]::new($false)");
expect(payloads).toContain("Write-OpenClawUtf8File $exitPath '0'");
expect(payloads).toContain("Write-OpenClawUtf8File $donePath 'done'");
expect(commands).toContain("Write-OpenClawUtf8File $pidPath ([string]$process.Id)");
expect(commands).toContain("Start-Process -FilePath powershell.exe");
expect(commands).toContain("-PassThru");
expect(commands).toContain("[System.IO.File]::Open($logPath");
expect(commands).toContain("[Math]::Min($length - $offset, 64)");
expect(payloads).toContain("Write-OpenClawUtf8File $pidPath ([string]$PID)");
expect(commands).toContain('cmd.exe /d /s /c start "" /b powershell.exe');
expect(commands).toContain("icacls.exe $runDir /inheritance:r");
expect(commands).toContain("Stop-OpenClawBackgroundProcessTree ([int]$backgroundPid)");
expect(commands).toContain(
'Get-CimInstance Win32_Process -Filter "ParentProcessId=$ProcessId"',
@ -677,22 +673,11 @@ exit 1
const fakeRun: typeof hostCommandRun = (_command, args) => {
const decoded = decodePowerShellFromArgs(args);
decodedCommands.push(decoded);
if (decoded.includes("Start-Process")) {
if (decoded.includes('cmd.exe /d /s /c start "" /b powershell.exe')) {
return { status: 0, stderr: "", stdout: "started\n" };
}
if (decoded.includes("__OPENCLAW_LOG_LENGTH__")) {
const markers = extractWindowsBackgroundControlMarkers(decoded);
return {
status: 0,
stderr: "",
stdout: [
`${markers.lengthPrefix}128`,
`${markers.offsetPrefix}128`,
"__OPENCLAW_BACKGROUND_EXIT__:0",
"__OPENCLAW_BACKGROUND_DONE__",
"",
].join("\n"),
};
if (args.includes("cmd.exe")) {
return { status: 0, stderr: "", stdout: "done\n" };
}
return { status: 0, stderr: "", stdout: "" };
};
@ -700,11 +685,11 @@ exit 1
await expect(
runWindowsBackgroundPowerShell({
label: "windows background marker smuggle",
logChunkBytes: 128,
pollIntervalMs: 1,
runCommand: fakeRun,
script: "Write-Output done",
timeoutMs: 5,
completedLogDrainGraceMs: 5,
vmName: "Windows Test",
}),
).rejects.toThrow("windows background marker smuggle timed out");
@ -721,34 +706,24 @@ exit 1
const fakeRun: typeof hostCommandRun = (_command, args) => {
const decoded = decodePowerShellFromArgs(args);
decodedCommands.push(decoded);
if (decoded.includes("Start-Process")) {
if (decoded.includes('cmd.exe /d /s /c start "" /b powershell.exe')) {
return { status: 0, stderr: "", stdout: "started\n" };
}
if (decoded.includes("__OPENCLAW_LOG_LENGTH__")) {
const markers = extractWindowsBackgroundControlMarkers(decoded);
pollCount += 1;
return {
status: 0,
stderr: "",
stdout:
pollCount === 1
? [
`${markers.lengthPrefix}128`,
`${markers.offsetPrefix}64`,
"first chunk",
`${markers.exitPrefix}0`,
markers.done,
"",
].join("\n")
: [
`${markers.lengthPrefix}128`,
`${markers.offsetPrefix}128`,
"second chunk",
`${markers.exitPrefix}0`,
markers.done,
"",
].join("\n"),
};
if (args.includes("cmd.exe")) {
const command = args.at(-1) ?? "";
if (command.includes("type")) {
pollCount += 1;
const markers = extractWindowsBackgroundControlMarkers(command);
return {
status: 0,
stderr: "",
stdout: ["first chunk", `${markers.exitPrefix}0`, markers.done, ""].join("\n"),
};
}
if (command.includes("if exist")) {
return { status: 0, stderr: "", stdout: "done\n" };
}
return { status: 0, stderr: "", stdout: "" };
}
return { status: 0, stderr: "", stdout: "" };
};
@ -758,7 +733,6 @@ exit 1
append: (chunk) => output.push(String(chunk)),
completedLogDrainGraceMs: 1000,
label: "windows background drain",
logChunkBytes: 64,
pollIntervalMs: 5000,
runCommand: fakeRun,
script: "Write-Output done",
@ -767,9 +741,8 @@ exit 1
}),
).resolves.toBeUndefined();
expect(pollCount).toBe(2);
expect(pollCount).toBe(1);
expect(output.join("")).toContain("first chunk");
expect(output.join("")).toContain("second chunk");
expect(decodedCommands.join("\n")).not.toContain("Stop-OpenClawBackgroundProcessTree");
expect(decodedCommands.join("\n")).toContain(
"Remove-Item -Path $scriptPath, $logPath, $donePath, $exitPath, $pidPath",

View file

@ -424,7 +424,9 @@ describe("Parallels smoke model selection", () => {
expect(script).not.toContain("'openclaw-parallels-plugin-isolation.cjs'");
expect(script).toContain("try {");
expect(script).toContain("} finally {");
expect(script).toContain("Remove-Item $isolationScriptPath -Force -ErrorAction SilentlyContinue");
expect(script).toContain(
"Remove-Item $isolationScriptPath -Force -ErrorAction SilentlyContinue",
);
expect(script).toContain("Remove-Item Env:OPENCLAW_PARALLELS_PLUGIN_ISOLATION");
});
@ -1463,16 +1465,29 @@ if (isPrlctl) {
expect(script).toContain("guestPowerShellBackground");
expect(script).toContain("runWindowsBackgroundPowerShell");
expect(transports).toContain("Join-Path $env:TEMP");
expect(transports).toContain("Join-Path (Join-Path $env:WINDIR 'Temp\\\\openclaw-parallels')");
expect(transports).toContain("icacls.exe $runDir /inheritance:r");
expect(transports).toContain("__OPENCLAW_BACKGROUND_DONE__");
expect(transports).toContain("__OPENCLAW_BACKGROUND_EXIT__");
expect(transports).toContain("__OPENCLAW_LOG_OFFSET__");
expect(transports).toContain("poll.status !== 0 && poll.status !== 124");
expect(transports).toContain("Start-Process -FilePath powershell.exe");
expect(transports).toContain('cmd.exe /d /s /c start "" /b powershell.exe');
expect(transports).toContain('if exist "${windowsDonePath}"');
expect(transports).toContain('type "%WINDIR%\\\\Temp\\\\${guestRunDir}\\\\run.log"');
expect(transports).toContain("WINDOWS_BACKGROUND_LOG_MAX_BYTES");
expect(transports).toContain("Write-OpenClawUtf8File $pidPath ([string]$PID)");
expect(transports).toContain('launch.stdout.includes("started")');
expect(transports).toContain("waitForWindowsBackgroundMaterialized");
});
it("runs Windows package installs through the detached done-file runner", () => {
const script = readFileSync(TS_PATHS.windows, "utf8");
expect(script).toContain('guestPowerShellBackground(\n "install-latest"');
expect(script).toContain("guestPowerShellBackground(\n `install-main-${");
expect(script).not.toMatch(/private installMain\(tempName: string\): void/u);
expect(script).not.toMatch(/private installLatestRelease\(\): void/u);
});
it("paces ambiguous Windows background launch materialization probes", async () => {
let calls = 0;
const runCommand = vi.fn(() => {
@ -2096,9 +2111,7 @@ setInterval(() => {}, 1000);
);
expect(duplicateNpmUpdatePlatformResult.status).toBe(1);
expect(duplicateNpmUpdatePlatformResult.stderr).toContain(
"duplicate --platform entry: macos",
);
expect(duplicateNpmUpdatePlatformResult.stderr).toContain("duplicate --platform entry: macos");
expect(readFileSync(TS_PATHS.macos, "utf8")).toContain(
'this.updateDevTimeoutSeconds = readPositiveIntEnv(\n "OPENCLAW_PARALLELS_MACOS_UPDATE_DEV_TIMEOUT_S"',