Skip to content

Commit 1d3badd

Browse files
committed
Fix WSL provider timing and command deadlines
Include configured input delays in command timeouts and reject timer overflow before sending actions. Remove the extra string-typing delay without changing shared keyboard timing. Add regression tests for long delays, timer limits and public string typing configuration, and document the limits.
1 parent 955c350 commit 1d3badd

5 files changed

Lines changed: 147 additions & 21 deletions

File tree

‎README.md‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -296,7 +296,15 @@ reports a missing-helper error rather than compiling it or silently controlling
296296
the Linux desktop. The native package pin must be updated to that release when
297297
the two contributions are integrated.
298298

299-
The helper exits when its command pipe closes and releases input that it held.
299+
Input command timeouts include the configured provider delay. A command timeout
300+
must fit Node.js's maximum timer delay of 2,147,483,647 ms. Highlight durations
301+
above 2,147,453,647 ms are rejected before sending the command, leaving 30 seconds
302+
for completion. String typing uses the public keyboard delay without an
303+
additional helper delay.
304+
305+
The helper exits when its command pipe closes and attempts to release input
306+
that it held. Windows can reject those releases; failed releases remain tracked
307+
while the helper is running and shutdown failures are reported on standard error.
300308
Idle helpers do not keep completed Node.js scripts alive. A failed or timed-out
301309
command is not automatically repeated because a click or keystroke might
302310
already have reached Windows.

‎providers/libnut/lib/wsl/host-client.spec.ts‎

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { ChildProcessWithoutNullStreams } from "child_process";
22
import { EventEmitter } from "events";
33
import { PassThrough, Writable } from "stream";
4-
import { WindowsHostClient } from "./host-client";
4+
import { commandTimeout, WindowsHostClient } from "./host-client";
55

66
jest.mock("fs", () => ({ existsSync: jest.fn(() => true) }));
77

@@ -69,6 +69,11 @@ describe("WindowsHostClient", () => {
6969
remote.child.stdout.emit("data", Buffer.from('{"id":2,"error":"input blocked","byteLength":0}\n'));
7070
await rejected;
7171
expect(remote.requests.filter(request => request.command === "click")).toHaveLength(1);
72+
const next = remote.client.request("screenSize");
73+
await Promise.resolve();
74+
remote.reply(3, { width: 100, height: 50 });
75+
expect((await next).result).toEqual({ width: 100, height: 50 });
76+
expect(remote.spawn).toHaveBeenCalledTimes(1);
7277
});
7378

7479
it.each([
@@ -123,4 +128,32 @@ describe("WindowsHostClient", () => {
123128
await expect(remote.client.request("type", { text: "a".repeat(1024 * 1024) })).rejects.toThrow("exceeds 1 MiB");
124129
expect(remote.requests.map(request => request.command)).toEqual(["hello"]);
125130
});
131+
132+
it("waits for accepted delays above 30 seconds", async () => {
133+
jest.useFakeTimers();
134+
const remote = setup();
135+
const response = remote.client.request("keys", { keys: ["a"], down: true, delay: 60000 }, commandTimeout(60000));
136+
await Promise.resolve(); await Promise.resolve(); await Promise.resolve();
137+
jest.advanceTimersByTime(60000);
138+
remote.reply(2, null);
139+
expect(await response).toEqual({ result: null, pixels: Buffer.alloc(0) });
140+
expect(remote.child.stdin.writableEnded).toBe(false);
141+
});
142+
143+
it.each([0, -1, NaN, Infinity, 1.5, 2147483648, 2400030000])("rejects invalid timeout %s before starting the helper", async timeout => {
144+
const remote = setup();
145+
await expect(remote.client.request("type", {}, timeout)).rejects.toThrow("timeout");
146+
expect(remote.spawn).not.toHaveBeenCalled();
147+
expect(remote.requests).toHaveLength(0);
148+
});
149+
150+
it("accepts the exact maximum timer delay without shortening it", async () => {
151+
jest.useFakeTimers();
152+
const remote = setup();
153+
const response = remote.client.request("screenSize", {}, 2147483647);
154+
await Promise.resolve(); await Promise.resolve(); await Promise.resolve();
155+
jest.advanceTimersByTime(60000);
156+
remote.reply(2, { width: 100, height: 50 });
157+
expect((await response).result).toEqual({ width: 100, height: 50 });
158+
});
126159
});

‎providers/libnut/lib/wsl/host-client.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,19 @@ const maxRequestBytes = 1024 * 1024;
66
const maxImageBytes = 128 * 1024 * 1024;
77
const maxHeaderBytes = 1024 * 1024;
88
const defaultTimeout = 30000;
9+
const maxTimeout = 2147483647;
10+
11+
export function commandTimeout(duration = 0): number {
12+
const timeout = defaultTimeout + duration;
13+
validateTimeout(timeout);
14+
return timeout;
15+
}
16+
17+
function validateTimeout(timeout: number): void {
18+
if (!Number.isInteger(timeout) || timeout < 1 || timeout > maxTimeout) {
19+
throw new Error(`Windows host timeout must be between 1 and ${maxTimeout} ms`);
20+
}
21+
}
922

1023
export interface HostResponse<T> {
1124
result: T;
@@ -61,6 +74,9 @@ export class WindowsHostClient {
6174
) {}
6275

6376
async request<T>(command: string, args: Record<string, unknown> = {}, timeout = defaultTimeout): Promise<HostResponse<T>> {
77+
// Validate before starting the helper or sending an action. Node replaces
78+
// overflowing timer delays with 1 ms, which would fail after input occurs.
79+
validateTimeout(timeout);
6480
if (this.failure) throw this.failure;
6581
if (!this.ready) this.ready = this.start();
6682
await this.ready;

‎providers/libnut/lib/wsl/providers.spec.ts‎

Lines changed: 74 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,16 @@
11
import { Button, ColorMode, Key, Point, Region, Size } from "@nut-tree/shared";
22
import { WindowsHostClient } from "./host-client";
33
import { createWindowsProviders } from "./providers";
4+
import { KeyboardClass } from "../../../../core/nut.js/lib/keyboard.class";
5+
import { NoopLogProvider } from "../../../../core/nut.js/lib/provider/log/noop-log-provider.class";
6+
import { ProviderRegistry } from "@nut-tree/provider-interfaces";
7+
8+
// sleep() uses the global registry only for logging. Do not load native defaults
9+
// when exercising the real KeyboardClass and its actual timer implementation.
10+
jest.mock("../../../../core/nut.js/lib/provider/provider-registry.class", () => ({
11+
__esModule: true,
12+
default: { getLogProvider: () => ({ debug: jest.fn() }) }
13+
}));
414

515
describe("WSL Windows providers", () => {
616
function setup() {
@@ -23,13 +33,13 @@ describe("WSL Windows providers", () => {
2333
await providers.keyboard.click(...keys);
2434
expect(keys).toEqual([Key.LeftControl, Key.LeftShift, Key.A]);
2535
expect(request).toHaveBeenCalledTimes(1);
26-
expect(request).toHaveBeenCalledWith("keyClick", { keys: ["control", "shift", "a"], delay: 10 });
36+
expect(request).toHaveBeenCalledWith("keyClick", { keys: ["control", "shift", "a"], delay: 10 }, 30010);
2737
});
2838

2939
it("maps function, right modifier and keypad keys", async () => {
3040
const { request, providers } = setup();
3141
await providers.keyboard.pressKey(Key.RightSuper, Key.F24, Key.NumPad9);
32-
expect(request).toHaveBeenCalledWith("keys", { keys: ["right_win", "f24", "numpad_9"], down: true, delay: 10 });
42+
expect(request).toHaveBeenCalledWith("keys", { keys: ["right_win", "f24", "numpad_9"], down: true, delay: 10 }, 30010);
3343
});
3444

3545
it("rejects unsupported keys before sending input", async () => {
@@ -42,16 +52,16 @@ describe("WSL Windows providers", () => {
4252
const { request, providers } = setup();
4353
await providers.mouse.scrollUp(120); await providers.mouse.scrollDown(120);
4454
await providers.mouse.scrollLeft(120); await providers.mouse.scrollRight(120);
45-
expect(request).toHaveBeenNthCalledWith(1, "scroll", { amount: 120, horizontal: false, delay: 100 });
46-
expect(request).toHaveBeenNthCalledWith(2, "scroll", { amount: -120, horizontal: false, delay: 100 });
47-
expect(request).toHaveBeenNthCalledWith(3, "scroll", { amount: -120, horizontal: true, delay: 100 });
48-
expect(request).toHaveBeenNthCalledWith(4, "scroll", { amount: 120, horizontal: true, delay: 100 });
55+
expect(request).toHaveBeenNthCalledWith(1, "scroll", { amount: 120, horizontal: false, delay: 100 }, 30100);
56+
expect(request).toHaveBeenNthCalledWith(2, "scroll", { amount: -120, horizontal: false, delay: 100 }, 30100);
57+
expect(request).toHaveBeenNthCalledWith(3, "scroll", { amount: -120, horizontal: true, delay: 100 }, 30100);
58+
expect(request).toHaveBeenNthCalledWith(4, "scroll", { amount: 120, horizontal: true, delay: 100 }, 30100);
4959
});
5060

5161
it("uses native desktop coordinates including negative monitor positions", async () => {
5262
const { request, providers } = setup();
5363
await providers.mouse.setMousePosition(new Point(-100, 200));
54-
expect(request).toHaveBeenCalledWith("moveMouse", { x: -100, y: 200, delay: 100 });
64+
expect(request).toHaveBeenCalledWith("moveMouse", { x: -100, y: 200, delay: 100 }, 30100);
5565
});
5666

5767
it("returns a nut.js Image directly from pixel data", async () => {
@@ -108,4 +118,61 @@ describe("WSL Windows providers", () => {
108118
await expect(providers.mouse.setMousePosition(new Point(NaN, 0))).rejects.toThrow("x");
109119
expect(request).not.toHaveBeenCalled();
110120
});
121+
122+
it("includes the maximum configured delay in all input command deadlines", async () => {
123+
const { request, providers } = setup();
124+
providers.keyboard.setKeyboardDelay(60000);
125+
providers.mouse.setMouseDelay(60000);
126+
await providers.keyboard.click(Key.A);
127+
await providers.keyboard.pressKey(Key.A);
128+
await providers.keyboard.releaseKey(Key.A);
129+
await providers.mouse.setMousePosition(new Point(0, 0));
130+
await providers.mouse.click(Button.LEFT);
131+
await providers.mouse.doubleClick(Button.LEFT);
132+
await providers.mouse.pressButton(Button.LEFT);
133+
await providers.mouse.releaseButton(Button.LEFT);
134+
await providers.mouse.scrollUp(120);
135+
for (const [, args, timeout] of request.mock.calls) {
136+
expect(args?.delay).toBe(60000);
137+
expect(timeout).toBe(90000);
138+
}
139+
expect(request).toHaveBeenCalledTimes(9);
140+
});
141+
142+
it("rejects overflowing highlight deadlines before sending the action", async () => {
143+
const { request, providers } = setup();
144+
const region = new Region(0, 0, 1, 1);
145+
await expect(providers.screen.highlightScreenRegion(region, 2147483647 - 30000 + 1, 0.5)).rejects.toThrow("timeout");
146+
expect(request).not.toHaveBeenCalled();
147+
await providers.screen.highlightScreenRegion(region, 2147483647 - 30000, 0.5);
148+
expect(request.mock.calls[0][2]).toBe(2147483647);
149+
});
150+
151+
it("does not add provider delay to direct string typing", async () => {
152+
const { request, providers } = setup();
153+
providers.keyboard.setKeyboardDelay(60000);
154+
await providers.keyboard.type("a".repeat(40000));
155+
expect(request).toHaveBeenCalledWith("type", { text: "a".repeat(40000), delay: 0 });
156+
});
157+
158+
it.each([0, 25, 300])("uses the current public string delay once (%i ms)", async milliseconds => {
159+
jest.useFakeTimers();
160+
try {
161+
const { request, providers } = setup();
162+
const registry = {
163+
hasKeyboard: () => true, getKeyboard: () => providers.keyboard,
164+
getLogProvider: () => new NoopLogProvider()
165+
} as unknown as ProviderRegistry;
166+
const keyboard = new KeyboardClass(registry);
167+
keyboard.config.autoDelayMs = milliseconds;
168+
const start = Date.now();
169+
const typing = keyboard.type("ab");
170+
await jest.runAllTimersAsync();
171+
await typing;
172+
expect(Date.now() - start).toBe(2 * milliseconds);
173+
expect(request.mock.calls).toEqual([
174+
["type", { text: "a", delay: 0 }], ["type", { text: "b", delay: 0 }]
175+
]);
176+
} finally { jest.useRealTimers(); }
177+
});
111178
});

‎providers/libnut/lib/wsl/providers.ts‎

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import {
33
ScreenProviderInterface, WindowProviderInterface
44
} from "@nut-tree/provider-interfaces";
55
import { Button, ColorMode, Image, Key, Point, Region, Size } from "@nut-tree/shared";
6-
import { WindowsHostClient } from "./host-client";
6+
import { commandTimeout, WindowsHostClient } from "./host-client";
77

88
function integer(value: number, name: string, minimum = -2147483648, maximum = 2147483647): number {
99
if (!Number.isInteger(value) || value < minimum || value > maximum) throw new Error(`Invalid ${name}: ${value}`);
@@ -44,16 +44,18 @@ class WindowsKeyboard implements KeyboardProviderInterface {
4444
constructor(private readonly host: WindowsHostClient) {}
4545
setKeyboardDelay(value: number): void { this.delay = delay(value); }
4646
async type(text: string): Promise<void> {
47-
await this.host.request("type", { text, delay: this.delay }, 30000 + text.length * this.delay);
47+
// KeyboardClass already spaces characters using its current public config.
48+
// Match native typeString: provider delays apply to key operations only.
49+
await this.host.request("type", { text, delay: 0 });
4850
}
4951
async click(...keys: Key[]): Promise<void> {
50-
await this.host.request("keyClick", { keys: keys.map(keyName), delay: this.delay });
52+
await this.host.request("keyClick", { keys: keys.map(keyName), delay: this.delay }, commandTimeout(this.delay));
5153
}
5254
async pressKey(...keys: Key[]): Promise<void> {
53-
await this.host.request("keys", { keys: keys.map(keyName), down: true, delay: this.delay });
55+
await this.host.request("keys", { keys: keys.map(keyName), down: true, delay: this.delay }, commandTimeout(this.delay));
5456
}
5557
async releaseKey(...keys: Key[]): Promise<void> {
56-
await this.host.request("keys", { keys: keys.map(keyName), down: false, delay: this.delay });
58+
await this.host.request("keys", { keys: keys.map(keyName), down: false, delay: this.delay }, commandTimeout(this.delay));
5759
}
5860
}
5961

@@ -68,29 +70,29 @@ class WindowsMouse implements MouseProviderInterface {
6870
throw new Error(`Invalid mouse button: ${value}`);
6971
}
7072
async setMousePosition(position: Point): Promise<void> {
71-
await this.host.request("moveMouse", { x: integer(position.x, "x"), y: integer(position.y, "y"), delay: this.delay });
73+
await this.host.request("moveMouse", { x: integer(position.x, "x"), y: integer(position.y, "y"), delay: this.delay }, commandTimeout(this.delay));
7274
}
7375
async currentMousePosition(): Promise<Point> {
7476
const { result } = await this.host.request<{ x: number; y: number }>("cursorPosition");
7577
return new Point(integer(result.x, "cursor x"), integer(result.y, "cursor y"));
7678
}
7779
async click(button: Button): Promise<void> {
78-
await this.host.request("click", { button: this.button(button), count: 1, delay: this.delay });
80+
await this.host.request("click", { button: this.button(button), count: 1, delay: this.delay }, commandTimeout(this.delay));
7981
}
8082
async doubleClick(button: Button): Promise<void> {
81-
await this.host.request("click", { button: this.button(button), count: 2, delay: this.delay });
83+
await this.host.request("click", { button: this.button(button), count: 2, delay: this.delay }, commandTimeout(this.delay));
8284
}
8385
leftClick(): Promise<void> { return this.click(Button.LEFT); }
8486
rightClick(): Promise<void> { return this.click(Button.RIGHT); }
8587
middleClick(): Promise<void> { return this.click(Button.MIDDLE); }
8688
async pressButton(button: Button): Promise<void> {
87-
await this.host.request("mouseButton", { button: this.button(button), down: true, delay: this.delay });
89+
await this.host.request("mouseButton", { button: this.button(button), down: true, delay: this.delay }, commandTimeout(this.delay));
8890
}
8991
async releaseButton(button: Button): Promise<void> {
90-
await this.host.request("mouseButton", { button: this.button(button), down: false, delay: this.delay });
92+
await this.host.request("mouseButton", { button: this.button(button), down: false, delay: this.delay }, commandTimeout(this.delay));
9193
}
9294
private async scroll(amount: number, horizontal: boolean): Promise<void> {
93-
await this.host.request("scroll", { amount: integer(amount, "scroll amount"), horizontal, delay: this.delay });
95+
await this.host.request("scroll", { amount: integer(amount, "scroll amount"), horizontal, delay: this.delay }, commandTimeout(this.delay));
9496
}
9597
scrollUp(amount: number): Promise<void> { return this.scroll(amount, false); }
9698
scrollDown(amount: number): Promise<void> { return this.scroll(-amount, false); }
@@ -116,7 +118,7 @@ class WindowsScreen implements ScreenProviderInterface {
116118
if (!Number.isFinite(opacity) || opacity < 0 || opacity > 1) throw new Error("Opacity must be between 0 and 1");
117119
const milliseconds = integer(duration, "highlight duration", 0);
118120
await this.host.request("highlight", { x: integer(region.left, "left"), y: integer(region.top, "top"),
119-
width: integer(region.width, "width", 1), height: integer(region.height, "height", 1), duration: milliseconds, opacity }, 30000 + milliseconds);
121+
width: integer(region.width, "width", 1), height: integer(region.height, "height", 1), duration: milliseconds, opacity }, commandTimeout(milliseconds));
120122
}
121123
async screenSize(): Promise<Region> {
122124
const { result } = await this.host.request<{ width: number; height: number }>("screenSize");

0 commit comments

Comments
 (0)