Skip to content

Commit bfebfdd

Browse files
authored
Merge pull request #1297 from streamich/fix-improve-fsa-error-handling
feat: 🎸 improve FS(A) error handling
2 parents bbcea18 + 55bc790 commit bfebfdd

4 files changed

Lines changed: 173 additions & 10 deletions

File tree

‎packages/fs-fsa-to-node/src/FsaNodeFs.ts‎

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -779,12 +779,14 @@ export class FsaNodeFs extends FsaNodeCore implements FsCallbackApi, FsSynchrono
779779
const handle = fd ? this.getFileByFdAsync(fd) : this.__open(filename, flags, 0);
780780
const stream = new FsaNodeWriteStream(handle, filename, optionsObj);
781781
if (optionsObj.autoClose) {
782-
stream.once('finish', () => {
783-
handle.then(file => this.close(file.fd, () => {}));
784-
});
785-
stream.once('error', () => {
786-
handle.then(file => this.close(file.fd, () => {}));
787-
});
782+
let closed = false;
783+
const close = () => {
784+
if (closed) return;
785+
closed = true;
786+
handle.then(file => this.close(file.fd, () => {})).catch(() => {});
787+
};
788+
stream.once('finish', close);
789+
stream.once('close', close);
788790
}
789791
return stream;
790792
};

‎packages/fs-fsa-to-node/src/FsaNodeReadStream.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export class FsaNodeReadStream extends Readable implements IReadStream {
2020
protected readonly options: IReadStreamOptions,
2121
) {
2222
super();
23+
this.__file__.promise.catch(error => this.destroy(error));
2324
handle
2425
.then(file => {
2526
if (this.__closed__) return;

‎packages/fs-fsa-to-node/src/FsaNodeWriteStream.ts‎

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ export class FsaNodeWriteStream extends Writable implements IWriteStream {
5252
}
5353
const stream = new Defer<IFileSystemWritableFileStream>();
5454
this.__stream__ = stream.promise;
55+
stream.promise.catch(() => {});
5556
(async () => {
5657
const fsaHandle = await handle;
5758
const fileWasOpened = !options.fd;
@@ -67,6 +68,7 @@ export class FsaNodeWriteStream extends Writable implements IWriteStream {
6768
stream.resolve(writable);
6869
})().catch(error => {
6970
stream.reject(error);
71+
this.destroy(error);
7072
});
7173
}
7274

@@ -82,9 +84,10 @@ export class FsaNodeWriteStream extends Writable implements IWriteStream {
8284
});
8385
}
8486

85-
private async __close__(): Promise<void> {
87+
/** @returns The error the close failed with, if any. */
88+
private async __close__(): Promise<unknown> {
8689
const emitClose = this.options.emitClose;
87-
await this.__mutex__(async () => {
90+
return await this.__mutex__<unknown>(async () => {
8891
if (this.__closed__ && emitClose) {
8992
queueMicrotask(() => this.emit('close'));
9093
return;
@@ -95,8 +98,9 @@ export class FsaNodeWriteStream extends Writable implements IWriteStream {
9598
await writable.close();
9699
if (emitClose) this.emit('close');
97100
} catch (error) {
98-
this.emit('error', error);
99-
if (emitClose) this.emit('close', error);
101+
if (this.errored !== error) this.emit('error', error);
102+
if (emitClose && !this.closed) this.emit('close', error);
103+
return error;
100104
}
101105
});
102106
}
@@ -112,6 +116,13 @@ export class FsaNodeWriteStream extends Writable implements IWriteStream {
112116
}
113117

114118
public close(cb): void {
119+
if (this.destroyed) {
120+
this.__close__().then(
121+
error => cb?.(error),
122+
() => cb?.(),
123+
);
124+
return;
125+
}
115126
if (cb) this.once('close', cb);
116127
this.__close__().catch(() => {});
117128
}

‎packages/fs-fsa-to-node/src/__tests__/FsaNodeFs.test.ts‎

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1012,6 +1012,133 @@ onlyOnNode20('FsaNodeFs', () => {
10121012
expect(error.message).toBe('"start" must be >= zero');
10131013
}
10141014
});
1015+
1016+
test('emits "error" and destroys the stream when the path is a directory', async () => {
1017+
const { fs } = setup({ folder: { file: 'test' } });
1018+
const stream = fs.createWriteStream('/folder');
1019+
const error = await new Promise<any>(resolve => stream.once('error', resolve));
1020+
expect(error.code).toBe('EISDIR');
1021+
expect(stream.destroyed).toBe(true);
1022+
});
1023+
1024+
test('a failed open does not leak an unhandled rejection', async () => {
1025+
const { fs } = setup({ folder: { file: 'test' } });
1026+
const unhandled: unknown[] = [];
1027+
const onUnhandled = (error: unknown) => unhandled.push(error);
1028+
process.on('unhandledRejection', onUnhandled);
1029+
try {
1030+
const stream = fs.createWriteStream('/folder');
1031+
stream.on('error', () => {});
1032+
await new Promise(resolve => stream.once('close', resolve));
1033+
await tick(10);
1034+
expect(unhandled).toStrictEqual([]);
1035+
} finally {
1036+
process.off('unhandledRejection', onUnhandled);
1037+
}
1038+
});
1039+
1040+
test('a failed open surfaces through the write callback', async () => {
1041+
const { fs } = setup({ folder: { file: 'test' } });
1042+
const stream = fs.createWriteStream('/folder');
1043+
stream.on('error', () => {});
1044+
const error = await new Promise<any>(resolve => stream.write(Buffer.from('A'), resolve));
1045+
expect(error.code).toBe('EISDIR');
1046+
});
1047+
1048+
test('a failed open is reported once, even if the stream is closed afterwards', async () => {
1049+
const { fs } = setup({ folder: { file: 'test' } });
1050+
const stream = fs.createWriteStream('/folder');
1051+
let errors = 0;
1052+
let closes = 0;
1053+
stream.on('error', () => errors++);
1054+
stream.on('close', () => closes++);
1055+
await new Promise(resolve => stream.once('close', resolve));
1056+
await new Promise<void>(resolve => stream.close(() => resolve()));
1057+
await tick(10);
1058+
expect(errors).toBe(1);
1059+
expect(closes).toBe(1);
1060+
});
1061+
1062+
test('autoClose closes the file descriptor even when "emitClose" is false', async () => {
1063+
const { fs } = setup({ folder: { file: 'test' } });
1064+
const handle = await fs.promises.open('/folder/file', 'a');
1065+
const stream = fs.createWriteStream('', { fd: handle.fd, start: 1, flags: 'a', emitClose: false });
1066+
stream.write(Buffer.from('BC'));
1067+
const stat = async () =>
1068+
await new Promise((resolve, reject) =>
1069+
fs.fstat(handle.fd, (err, stats) => {
1070+
if (err) reject(err);
1071+
else resolve(stats);
1072+
}),
1073+
);
1074+
await stat();
1075+
stream.end();
1076+
await until(async () => {
1077+
const [, error] = await of(stat());
1078+
return !!error;
1079+
});
1080+
const [, error] = await of(stat());
1081+
expect((error as any).code).toBe('EBADF');
1082+
});
1083+
1084+
test('autoClose closes the file descriptor exactly once', async () => {
1085+
const { fs } = setup({ folder: { file: 'test' } });
1086+
const handle = await fs.promises.open('/folder/file', 'a');
1087+
const closed: number[] = [];
1088+
const close = fs.close;
1089+
(fs as any).close = (fd: number, cb: any) => {
1090+
closed.push(fd);
1091+
return close(fd, cb);
1092+
};
1093+
const stream = fs.createWriteStream('', { fd: handle.fd, start: 1, flags: 'a' });
1094+
stream.write(Buffer.from('BC'));
1095+
stream.end();
1096+
await new Promise(resolve => stream.once('close', resolve));
1097+
await tick(20);
1098+
expect(closed).toStrictEqual([handle.fd]);
1099+
});
1100+
1101+
test('close() after a destroy() reports the close failure, and only once it has happened', async () => {
1102+
const { fs, mfs } = setup({ dir: { keep: 'x' } });
1103+
const stream = fs.createWriteStream('/dir/file.txt');
1104+
const order: string[] = [];
1105+
stream.on('error', (error: any) => order.push('error(' + error.code + ')'));
1106+
await new Promise(resolve => stream.once('open', resolve));
1107+
stream.write('important');
1108+
await tick(10);
1109+
mfs.rmSync('/mountpoint/dir', { recursive: true, force: true });
1110+
stream.destroy();
1111+
await new Promise<void>(resolve =>
1112+
stream.close((error: any) => {
1113+
order.push('cb(' + (error ? error.code : 'no error') + ')');
1114+
resolve();
1115+
}),
1116+
);
1117+
expect(order).toStrictEqual(['error(ENOENT)', 'cb(ENOENT)']);
1118+
});
1119+
1120+
test('a genuine close() failure is still reported after a plain destroy()', async () => {
1121+
const { fs, mfs } = setup({ dir: { keep: 'x' } });
1122+
const stream = fs.createWriteStream('/dir/file.txt');
1123+
const errors: string[] = [];
1124+
stream.on('error', (error: any) => errors.push(error.code));
1125+
await new Promise(resolve => stream.once('open', resolve));
1126+
stream.write('important');
1127+
await tick(10);
1128+
mfs.rmSync('/mountpoint/dir', { recursive: true, force: true });
1129+
stream.destroy();
1130+
stream.close(() => {});
1131+
await tick(20);
1132+
expect(errors).toStrictEqual(['ENOENT']);
1133+
});
1134+
1135+
test('an unhandled failed open is not masked by the autoClose listener', async () => {
1136+
const { fs } = setup({ folder: { file: 'test' } });
1137+
const stream = fs.createWriteStream('/folder');
1138+
expect(stream.listenerCount('error')).toBe(0);
1139+
stream.on('error', () => {});
1140+
await tick(10);
1141+
});
10151142
});
10161143

10171144
describe('.createReadStream()', () => {
@@ -1093,6 +1220,28 @@ onlyOnNode20('FsaNodeFs', () => {
10931220
});
10941221
});
10951222

1223+
test('emits "error" and destroys the stream when the file is missing', async () => {
1224+
const { fs } = setup({ folder: { file: 'test' } });
1225+
const stream = fs.createReadStream('/nope.txt');
1226+
const error = await new Promise<any>(resolve => stream.once('error', resolve));
1227+
expect(error.code).toBe('ENOENT');
1228+
expect(stream.destroyed).toBe(true);
1229+
});
1230+
1231+
test('a failed open does not leak an unhandled rejection', async () => {
1232+
const { fs } = setup({ folder: { file: 'test' } });
1233+
const unhandled: unknown[] = [];
1234+
const onUnhandled = (error: unknown) => unhandled.push(error);
1235+
process.on('unhandledRejection', onUnhandled);
1236+
try {
1237+
fs.createReadStream('/nope.txt').on('error', () => {});
1238+
await tick(10);
1239+
expect(unhandled).toStrictEqual([]);
1240+
} finally {
1241+
process.off('unhandledRejection', onUnhandled);
1242+
}
1243+
});
1244+
10961245
test('can read with start position at file size', async () => {
10971246
const { fs, vol } = setup({ folder: { file: 'test' }, 'empty-folder': null, 'f.html': 'test' });
10981247
const readStream = fs.createReadStream('/folder/file', { start: 4 });

0 commit comments

Comments
 (0)