Skip to content

Commit 595155a

Browse files
authored
v0.4.0 Improve performance of listening (#5)
In testing I discovered a couple of things: * Printers sometimes will provide much bigger transfer sizes if you offer them. * Being able to detect a printer is taking a while to respond is useful. This adds features for both. getInput now has an optional parameter to increase the requested message size. There is now an event loop in the listener promise, every 500ms it can run checks before resuming awaiting the same promise for input. This may get additional features in the future. Also more tests.
1 parent 5d2de4a commit 595155a

6 files changed

Lines changed: 297 additions & 108 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "web-device-mux",
3-
"version": "0.3.0",
3+
"version": "0.4.0",
44
"description": "A small library for managing connections to WebUSB, WebHID, and Web Serial devices from multiple tabs at the same time.",
55
"type": "module",
66
"repository": {

src/InputListener.test.ts

Lines changed: 91 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,98 @@
1-
import { test, expect, describe } from 'vitest';
2-
import { InputMessageListener } from './InputListener.js';
1+
import { it, expect, describe } from 'vitest';
2+
import { InputMessageListener, type InputHandler } from './InputListener.js';
3+
import { DeviceCommunicationError } from './Error.js';
4+
5+
function getValidListener(
6+
callback: InputHandler<string> = (i) => { console.log(i); return Promise.resolve({}); }
7+
) {
8+
return new InputMessageListener<string>(
9+
async () => {
10+
await new Promise(resolve => setTimeout(resolve, 50));
11+
return ['hello'];
12+
},
13+
callback
14+
);
15+
}
16+
17+
function getErrorListener() {
18+
return new InputMessageListener<string>(
19+
async () => {
20+
return new DeviceCommunicationError("This is a test error");
21+
},
22+
async (input) => { return {remainderData: input}}
23+
);
24+
}
25+
26+
function getSlowListener(
27+
callback: InputHandler<string> = (i) => { console.log(i); return Promise.resolve({}); }
28+
) {
29+
return new InputMessageListener<string>(
30+
async () => {
31+
await new Promise(resolve => setTimeout(resolve, 510));
32+
return ['hello, but slowly.'];
33+
},
34+
callback
35+
);
36+
}
337

438
describe('InputListener Disposes Cleanly', () => {
5-
test('Dispose does not throw', () => {
6-
const listener = new InputMessageListener<string>(
7-
async () => {
8-
await new Promise(resolve => setTimeout(resolve, 250));
9-
return ['hello '];
10-
},
11-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
12-
async (_input) => { return {}}
13-
);
39+
it('Dispose does not throw', () => {
40+
const listener = getValidListener();
41+
expect(listener["_disposed"]).toBe(false);
1442
listener.start();
43+
expect(listener["_disposed"]).toBe(false);
1544
listener.dispose();
1645
expect(listener["_disposed"]).toEqual(true);
1746
});
47+
48+
it('Double dispose does not throw', () => {
49+
const listener = getValidListener();
50+
expect(listener["_disposed"]).toBe(false);
51+
listener.start();
52+
expect(listener["_disposed"]).toBe(false);
53+
listener.dispose();
54+
expect(listener["_disposed"]).toBe(true);
55+
listener.dispose();
56+
expect(listener["_disposed"]).toBe(true);
57+
});
58+
59+
it('Dispose on error', async () => {
60+
const listener = getErrorListener();
61+
expect(listener["_disposed"]).toBe(false);
62+
listener.start();
63+
await new Promise(resolve => setTimeout(resolve, 50));
64+
expect(listener["_disposed"]).toBe(true);
65+
});
66+
67+
it('Slow listener returns eventually', async () => {
68+
let awaitResolve: (value: boolean) => void;
69+
const signal = new Promise<boolean>((resolve) => { awaitResolve = resolve});
70+
const callback = (i: string[]) => {
71+
awaitResolve(true);
72+
expect(i).toStrictEqual(['hello, but slowly.']);
73+
return Promise.resolve({});
74+
}
75+
const listener = getSlowListener(callback);
76+
listener.start();
77+
await signal;
78+
});
79+
80+
it('Disposed slow listener is ignored', async () => {
81+
let awaitResolve: (value: boolean) => void;
82+
const signal = new Promise<boolean>((resolve) => { awaitResolve = resolve});
83+
const callback = (i: string[]) => {
84+
awaitResolve(true);
85+
console.log(i);
86+
return Promise.resolve({});
87+
}
88+
const listener = getSlowListener(callback);
89+
listener.start();
90+
// Disposing immediately after starting should prevent the signal from resolving.
91+
listener.dispose();
92+
const result = await Promise.race([
93+
signal,
94+
new Promise<string>(resolve => setTimeout(() => resolve('Correct!'), 510))
95+
]);
96+
expect(result).toEqual('Correct!');
97+
});
1898
});

src/InputListener.ts

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,24 @@ export interface IHandlerResponse<TInput> {
88
remainderData?: TInput[];
99
}
1010

11+
function promiseWithTimeoutDefault<T>(
12+
promise: Promise<T>,
13+
ms: number,
14+
resolveDefault: T
15+
): Promise<T> {
16+
// create a promise that rejects in milliseconds
17+
const timeout = new Promise<T>((resolve) => {
18+
setTimeout(() => {
19+
resolve(resolveDefault);
20+
}, ms);
21+
});
22+
23+
// returns a race between timeout and the passed promise
24+
return Promise.race<T>([promise, timeout]);
25+
}
26+
27+
const listenSentinel = new DeviceCommunicationError('ListenSentinel');
28+
1129
export class InputMessageListener<TInput> {
1230
public start() {
1331
// We intentionally want to kick off an async promise here without needing
@@ -16,8 +34,22 @@ export class InputMessageListener<TInput> {
1634
// eslint-disable-next-line no-async-promise-executor
1735
new Promise<void>(async (_, reject) => {
1836
let aggregate: TInput[] = [];
37+
let inputPromise: undefined | Promise<TInput[] | DeviceCommunicationError> = undefined;
1938
do {
20-
const data = await this._dataProvider();
39+
// Allow resuming awaiting an already-in-progress promise.
40+
inputPromise ??= this._dataProvider();
41+
const data = await promiseWithTimeoutDefault(inputPromise, 500, listenSentinel);
42+
43+
// Printers can idle or otherwise not response for long periods of time.
44+
// We have a little event loop here, where every 500 milliseconds we
45+
// can do some housekeeping to see if we should continue waiting or give
46+
// up entirely.
47+
if (data === listenSentinel) {
48+
continue;
49+
} else {
50+
// This indicates we've left our event loop and got a real result.
51+
inputPromise = undefined;
52+
}
2153

2254
if (data instanceof DeviceCommunicationError) {
2355
console.error(`Error getting data from source: `, data);
@@ -39,14 +71,15 @@ export class InputMessageListener<TInput> {
3971
}
4072

4173
// The handler is not required to be stateful, this is instead.
42-
// The handler may indicate more data is expected by returning incomplete
74+
// The handler may indicate more data is expected by returning incomplete
4375
// data back. We prefix our buffer with that incomplete data to add more
4476
// to it and wait again for more.
4577
aggregate = handleResult.remainderData ?? [];
4678
} while (!this._disposed);
4779
})
4880
.catch((reason) => {
4981
// TODO: Better logging?
82+
this._disposed = true;
5083
this.logIfDebug(`Device stream listener stopped listening unexpectedly.`, reason);
5184
});
5285
}

0 commit comments

Comments
 (0)