Skip to content

Commit

Permalink
refactor: better resource management (#23)
Browse files Browse the repository at this point in the history
* refactor: better resource management

- use disposables anywhere possible
- fix the source map sources field to better work with Node.js
- print errored browser logs instead of breaking the whole process
- split the test server into a separate class

* fix(common): disposable polyfill method strict undefined check
  • Loading branch information
PaperStrike authored Oct 29, 2023
1 parent bc343db commit dab0148
Show file tree
Hide file tree
Showing 9 changed files with 839 additions and 434 deletions.
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,8 @@ If you want Node.js API,
```ts
import { Runner } from 'wrightplay/node';

const runner = new Runner({
// Or manually calling `runner.dispose()` to release resources
await using runner = new Runner({
setup: 'test/setup.ts',
tests: 'test/**/*.spec.ts',
});
Expand Down
2 changes: 1 addition & 1 deletion src/cli/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ export const program = command
const runnerOptionsList = await parseRunnerOptionsFromCLI(testAndEntries, options);
await runnerOptionsList.reduce(async (last, runnerOptions) => {
await last;
using runner = new Runner(runnerOptions);
await using runner = new Runner(runnerOptions);
const exitCode = await runner.runTests();
process.exitCode ||= exitCode;
}, Promise.resolve());
Expand Down
288 changes: 283 additions & 5 deletions src/common/utils/patchDisposable.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,286 @@
/**
* Simple polyfill that covers the `using` and `async using` use cases.
* Simple explicit resource management API polyfill.
*
* https://github.com/tc39/proposal-explicit-resource-management
*/

// @ts-expect-error polyfill
Symbol.dispose ??= Symbol('Symbol.dispose');
// @ts-expect-error polyfill
Symbol.asyncDispose ??= Symbol('Symbol.asyncDispose');
/* eslint-disable max-classes-per-file */
/* c8 ignore start */

if (!Symbol.dispose) {
Object.defineProperty(Symbol, 'dispose', {
value: Symbol('Symbol.dispose'),
writable: false,
enumerable: false,
configurable: false,
});
}

if (!Symbol.asyncDispose) {
Object.defineProperty(Symbol, 'asyncDispose', {
value: Symbol('Symbol.asyncDispose'),
writable: false,
enumerable: false,
configurable: false,
});
}

globalThis.SuppressedError ??= (() => {
const nonEnumerableDescriptor = { writable: true, enumerable: false, configurable: true };
const SEConstructor = function SuppressedError(
this: SuppressedError,
error: unknown,
suppressed: unknown,
message?: string,
) {
if (new.target === undefined) {
return new SEConstructor(error, suppressed, message);
}
if (message !== undefined) {
Object.defineProperty(this, 'message', { value: String(message), ...nonEnumerableDescriptor });
}
Object.defineProperties(this, {
error: { value: error, ...nonEnumerableDescriptor },
suppressed: { value: suppressed, ...nonEnumerableDescriptor },
});
} as SuppressedErrorConstructor;

Object.setPrototypeOf(SEConstructor.prototype, Error.prototype);
Object.defineProperties(SEConstructor.prototype, {
message: { value: '', ...nonEnumerableDescriptor },
name: { value: 'SuppressedError', ...nonEnumerableDescriptor },
});

return SEConstructor;
})();

globalThis.DisposableStack ??= class DisposableStack {
#disposed = false;

get disposed() {
return this.#disposed;
}

#stack: {
v: Disposable | undefined,
m: ((this: Disposable | undefined) => unknown),
}[] = [];

dispose() {
if (this.#disposed) return;
this.#disposed = true;

const stack = this.#stack;
this.#stack = [];

let hasError = false;
let error: unknown;

while (stack.length > 0) {
const { m, v } = stack.pop()!;
try {
m.call(v);
} catch (e) {
error = hasError ? new SuppressedError(e, error, 'An error was suppressed during disposal.') : e;
hasError = true;
}
}

if (hasError) {
throw error;
}
}

use<T extends Disposable | null | undefined>(value: T): T {
if (this.#disposed) {
throw new ReferenceError('This stack has already been disposed');
}

if (value !== null && value !== undefined) {
const method = Symbol.dispose in value
? value[Symbol.dispose]
: undefined;
if (typeof method !== 'function') {
throw new TypeError('The value is not disposable');
}
this.#stack.push({ v: value, m: method });
}

return value;
}

adopt<T>(value: T, onDispose: (value: T) => void): T {
if (this.#disposed) {
throw new ReferenceError('This stack has already been disposed');
}

if (typeof onDispose !== 'function') {
throw new TypeError('The callback is not a function');
}

this.#stack.push({ v: undefined, m: () => onDispose.call(undefined, value) });

return value;
}

defer(onDispose: () => void): void {
if (this.#disposed) {
throw new ReferenceError('This stack has already been disposed');
}

if (typeof onDispose !== 'function') {
throw new TypeError('The callback is not a function');
}

this.#stack.push({ v: undefined, m: onDispose });
}

move(): DisposableStack {
if (this.#disposed) {
throw new ReferenceError('This stack has already been disposed');
}

const stack = new DisposableStack();
stack.#stack = this.#stack;

this.#disposed = true;
this.#stack = [];

return stack;
}

[Symbol.dispose]() {
return this.dispose();
}

declare readonly [Symbol.toStringTag]: string;

static {
Object.defineProperty(this.prototype, Symbol.toStringTag, {
value: 'DisposableStack',
writable: false,
enumerable: false,
configurable: true,
});
}
};

globalThis.AsyncDisposableStack ??= class AsyncDisposableStack {
#disposed = false;

get disposed() {
return this.#disposed;
}

#stack: {
v: AsyncDisposable | Disposable | undefined,
m: ((this: AsyncDisposable | Disposable | undefined) => unknown) | undefined,
}[] = [];

async disposeAsync() {
if (this.#disposed) return;
this.#disposed = true;

const stack = this.#stack;
this.#stack = [];

let hasError = false;
let error: unknown;

while (stack.length > 0) {
const { m, v } = stack.pop()!;
try {
// eslint-disable-next-line no-await-in-loop
await (m?.call(v));
} catch (e) {
error = hasError ? new SuppressedError(e, error, 'An error was suppressed during disposal.') : e;
hasError = true;
}
}

if (hasError) {
throw error;
}
}

use<T extends AsyncDisposable | Disposable | null | undefined>(value: T): T {
if (this.#disposed) {
throw new ReferenceError('This async stack has already been disposed');
}

if (value === null || value === undefined) {
this.#stack.push({ v: undefined, m: undefined });
} else {
let method = Symbol.asyncDispose in value
? value[Symbol.asyncDispose] as () => unknown
: undefined;
if (method === undefined) {
const syncDispose = Symbol.dispose in value ? value[Symbol.dispose] : undefined;
if (typeof syncDispose === 'function') {
method = function omitReturnValue(this: unknown) { syncDispose.call(this); };
}
}
if (typeof method !== 'function') {
throw new TypeError('The value is not disposable');
}
this.#stack.push({ v: value, m: method });
}

return value;
}

adopt<T>(value: T, onDisposeAsync: (value: T) => PromiseLike<void> | void): T {
if (this.#disposed) {
throw new ReferenceError('This async stack has already been disposed');
}

if (typeof onDisposeAsync !== 'function') {
throw new TypeError('The callback is not a function');
}

this.#stack.push({ v: undefined, m: () => onDisposeAsync.call(undefined, value) });

return value;
}

defer(onDisposeAsync: () => PromiseLike<void> | void): void {
if (this.#disposed) {
throw new ReferenceError('This async stack has already been disposed');
}

if (typeof onDisposeAsync !== 'function') {
throw new TypeError('The callback is not a function');
}

this.#stack.push({ v: undefined, m: onDisposeAsync });
}

move(): AsyncDisposableStack {
if (this.#disposed) {
throw new ReferenceError('This async stack has already been disposed');
}

const stack = new AsyncDisposableStack();
stack.#stack = this.#stack;

this.#disposed = true;
this.#stack = [];

return stack;
}

[Symbol.asyncDispose]() {
return this.disposeAsync();
}

declare readonly [Symbol.toStringTag]: string;

static {
Object.defineProperty(this.prototype, Symbol.toStringTag, {
value: 'AsyncDisposableStack',
writable: false,
enumerable: false,
configurable: true,
});
}
};
Loading

0 comments on commit dab0148

Please sign in to comment.