fix(clipboard): hardened WSL PowerShell fallback for headless environments

- Raised PowerShell timeout to 8s and swallowed reap errors to prevent unhandled throws on WSL interop.
- Fixed fallback logic so arboard is skipped when no display server is present on headless WSL.
- Added test coverage for the headless WSL short-circuit path.
This commit is contained in:
can1357
2026-05-25 14:03:06 +02:00
parent 79f6bf4f76
commit 81cec1c38b
2 changed files with 30 additions and 5 deletions
+14 -3
View File
@@ -1,6 +1,7 @@
import { execSync } from "node:child_process";
import type { ClipboardImage } from "@oh-my-pi/pi-natives";
import * as native from "@oh-my-pi/pi-natives";
import { logger } from "@oh-my-pi/pi-utils";
function hasDisplay(): boolean {
return process.platform !== "linux" || Boolean(process.env.DISPLAY || process.env.WAYLAND_DISPLAY);
@@ -80,7 +81,7 @@ if ($img -ne $null) {
}
`;
const POWERSHELL_TIMEOUT_MS = 5000;
const POWERSHELL_TIMEOUT_MS = 8000;
/**
* Read a clipboard image through the Windows host's PowerShell.
@@ -104,6 +105,12 @@ async function readImageViaPowerShell(): Promise<ClipboardImage | null> {
try {
stdout = await new Response(proc.stdout).text();
await proc.exited;
} catch (err) {
// powershell.exe is a Windows process reached over WSL interop; if it
// doesn't reap cleanly, swallow the error so the dispatcher can fall
// through to the native bridge instead of throwing.
logger.warn("clipboard: powershell read failed", { error: String(err) });
return null;
} finally {
clearTimeout(timer);
}
@@ -136,8 +143,12 @@ export async function readImageFromClipboard(): Promise<ClipboardImage | null> {
if (isWsl()) {
const image = await readImageViaPowerShell();
if (image) return image;
// Fall through: arboard may still succeed on a future WSLg release.
} else if (!hasDisplay()) {
// Fall through: arboard may still succeed on a future WSLg release —
// but only when we actually have a display server. Headless WSL has
// no display, so arboard would reject anyway.
}
if (!hasDisplay()) {
return null;
}
@@ -97,9 +97,10 @@ describe("readImageFromClipboard on WSL", () => {
expect(nativeSpy).not.toHaveBeenCalled();
});
it("falls back to the native bridge when PowerShell returns no payload", async () => {
it("falls back to the native bridge when PowerShell returns no payload and a display is present", async () => {
setPlatform("linux");
process.env.WSL_INTEROP = "/run/WSL/1_interop";
process.env.WAYLAND_DISPLAY = "wayland-0";
spyPowershell([], "");
const nativeSpy = vi.spyOn(native, "readImageFromClipboard").mockResolvedValue(null);
@@ -110,9 +111,10 @@ describe("readImageFromClipboard on WSL", () => {
expect(nativeSpy).toHaveBeenCalledTimes(1);
});
it("falls back to the native bridge when PowerShell exits non-zero", async () => {
it("falls back to the native bridge when PowerShell exits non-zero (with display)", async () => {
setPlatform("linux");
process.env.WSL_DISTRO_NAME = "Ubuntu";
process.env.DISPLAY = ":0";
spyPowershell([], "noise", 1);
const nativeSpy = vi.spyOn(native, "readImageFromClipboard").mockResolvedValue(null);
@@ -120,6 +122,18 @@ describe("readImageFromClipboard on WSL", () => {
await readImageFromClipboard();
expect(nativeSpy).toHaveBeenCalledTimes(1);
});
it("returns null without invoking arboard on headless WSL when PowerShell yields nothing", async () => {
setPlatform("linux");
process.env.WSL_DISTRO_NAME = "Ubuntu";
// No DISPLAY / WAYLAND_DISPLAY — arboard would reject, so we must short-circuit.
spyPowershell([], "");
const nativeSpy = vi.spyOn(native, "readImageFromClipboard");
expect(await readImageFromClipboard()).toBeNull();
expect(nativeSpy).not.toHaveBeenCalled();
});
});
describe("readImageFromClipboard dispatch", () => {