Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fs: improve errors thrown from fs.watch() #19089

Closed
wants to merge 2 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions doc/api/errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -783,6 +783,18 @@ falsy value.
An invalid symlink type was passed to the [`fs.symlink()`][] or
[`fs.symlinkSync()`][] methods.

<a id="ERR_FS_WATCHER_ALREADY_STARTED"></a>
### ERR_FS_WATCHER_ALREADY_STARTED

An attempt was made to start a watcher returned by `fs.watch()` that has
already been started.

<a id="ERR_FS_WATCHER_NOT_STARTED"></a>
### ERR_FS_WATCHER_NOT_STARTED

An attempt was made to initiate operations on a watcher returned by
`fs.watch()` that has not yet been started.

<a id="ERR_HTTP_HEADERS_SENT"></a>
### ERR_HTTP_HEADERS_SENT

Expand Down
41 changes: 33 additions & 8 deletions lib/fs.js
Original file line number Diff line number Diff line change
Expand Up @@ -77,13 +77,19 @@ Object.defineProperty(exports, 'constants', {
value: constants
});

let assert_ = null;
function lazyAssert() {
if (assert_ === null) {
assert_ = require('assert');
}
return assert_;
}

const kMinPoolSpace = 128;
const { kMaxLength } = require('buffer');

const isWindows = process.platform === 'win32';

const errnoException = errors.errnoException;

let truncateWarn = true;

function showTruncateDeprecation() {
Expand Down Expand Up @@ -1312,11 +1318,17 @@ function FSWatcher() {
this._handle.owner = this;

this._handle.onchange = function(status, eventType, filename) {
// TODO(joyeecheung): we may check self._handle.initialized here
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cc @bnoordhuis can you check that this can be fixed this way? Thanks!

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BTW, this is coming from:

node/src/fs_event_wrap.cc

Lines 161 to 171 in 9e9c516

// We're in a bind here. libuv can set both UV_RENAME and UV_CHANGE but
// the Node API only lets us pass a single event to JS land.
//
// The obvious solution is to run the callback twice, once for each event.
// However, since the second event is not allowed to fire if the handle is
// closed after the first event, and since there is no good way to detect
// closed handles, that option is out.
//
// For now, ignore the UV_CHANGE event if UV_RENAME is also set. Make the
// assumption that a rename implicitly means an attribute change. Not too
// unreasonable, right? Still, we should revisit this before v1.0.

// and return if that is false. This allows us to avoid firing the event
// after the handle is closed, and to fire both UV_RENAME and UV_CHANGE
// if they are set by libuv at the same time.
if (status < 0) {
self._handle.close();
const error = !filename ?
errnoException(status, 'Error watching file for changes:') :
errnoException(status, `Error watching file ${filename} for changes:`);
const error = errors.uvException({
errno: status,
syscall: 'watch',
path: filename
});
error.filename = filename;
self.emit('error', error);
} else {
Expand All @@ -1335,21 +1347,34 @@ FSWatcher.prototype.start = function(filename,
persistent,
recursive,
encoding) {
lazyAssert()(this._handle instanceof FSEvent, 'handle must be a FSEvent');
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since very recently we have the (D)CHECK macro. I think it would be nice to replace these assert calls with those instead :-).

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@BridgeAR Not unless CHECK throws instead of aborting...at least currently we only expects an error thrown in sequential/test-fs-watch.js

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well... it is a semver-major PR. But this is also just a suggestion.

if (this._handle.initialized) {
throw new errors.Error('ERR_FS_WATCHER_ALREADY_STARTED');
}

filename = getPathFromURL(filename);
nullCheck(filename, 'filename');
validatePath(filename, 'filename');

var err = this._handle.start(pathModule.toNamespacedPath(filename),
persistent,
recursive,
encoding);
if (err) {
this._handle.close();
const error = errnoException(err, `watch ${filename}`);
const error = errors.uvException({
errno: err,
syscall: 'watch',
path: filename
});
error.filename = filename;
throw error;
}
};

FSWatcher.prototype.close = function() {
lazyAssert()(this._handle instanceof FSEvent, 'handle must be a FSEvent');
if (!this._handle.initialized) {
throw new errors.Error('ERR_FS_WATCHER_NOT_STARTED');
}
this._handle.close();
};

Expand Down
4 changes: 4 additions & 0 deletions lib/internal/errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -655,6 +655,10 @@ E('ERR_FALSY_VALUE_REJECTION', 'Promise was rejected with falsy value', Error);
E('ERR_FS_INVALID_SYMLINK_TYPE',
'Symlink type must be one of "dir", "file", or "junction". Received "%s"',
Error); // Switch to TypeError. The current implementation does not seem right
E('ERR_FS_WATCHER_ALREADY_STARTED',
'The watcher has already been started', Error);
E('ERR_FS_WATCHER_NOT_STARTED',
'The watcher has not been started', Error);
E('ERR_HTTP2_ALTSVC_INVALID_ORIGIN',
'HTTP/2 ALTSVC frames require a valid origin', TypeError);
E('ERR_HTTP2_ALTSVC_LENGTH',
Expand Down
73 changes: 46 additions & 27 deletions src/fs_event_wrap.cc
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,18 @@
namespace node {

using v8::Context;
using v8::DontDelete;
using v8::DontEnum;
using v8::FunctionCallbackInfo;
using v8::FunctionTemplate;
using v8::HandleScope;
using v8::Integer;
using v8::Local;
using v8::MaybeLocal;
using v8::Object;
using v8::PropertyAttribute;
using v8::ReadOnly;
using v8::Signature;
using v8::String;
using v8::Value;

Expand All @@ -51,7 +56,7 @@ class FSEventWrap: public HandleWrap {
static void New(const FunctionCallbackInfo<Value>& args);
static void Start(const FunctionCallbackInfo<Value>& args);
static void Close(const FunctionCallbackInfo<Value>& args);

static void GetInitialized(const FunctionCallbackInfo<Value>& args);
size_t self_size() const override { return sizeof(*this); }

private:
Expand Down Expand Up @@ -80,6 +85,11 @@ FSEventWrap::~FSEventWrap() {
CHECK_EQ(initialized_, false);
}

void FSEventWrap::GetInitialized(const FunctionCallbackInfo<Value>& args) {
FSEventWrap* wrap = Unwrap<FSEventWrap>(args.This());
CHECK(wrap != nullptr);
args.GetReturnValue().Set(wrap->initialized_);
}

void FSEventWrap::Initialize(Local<Object> target,
Local<Value> unused,
Expand All @@ -95,6 +105,18 @@ void FSEventWrap::Initialize(Local<Object> target,
env->SetProtoMethod(t, "start", Start);
env->SetProtoMethod(t, "close", Close);

Local<FunctionTemplate> get_initialized_templ =
FunctionTemplate::New(env->isolate(),
GetInitialized,
env->as_external(),
Signature::New(env->isolate(), t));

t->PrototypeTemplate()->SetAccessorProperty(
FIXED_ONE_BYTE_STRING(env->isolate(), "initialized"),
get_initialized_templ,
Local<FunctionTemplate>(),
static_cast<PropertyAttribute>(ReadOnly | DontDelete | v8::DontEnum));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a reason to make it an accessor instead of a method?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@bnoordhuis No strong reasons other than this way you can't assign it to something else or wrap it...probably plays more nicely with inspector?


target->Set(fsevent_string, t->GetFunction());
}

Expand All @@ -105,22 +127,19 @@ void FSEventWrap::New(const FunctionCallbackInfo<Value>& args) {
new FSEventWrap(env, args.This());
}


// wrap.start(filename, persistent, recursive, encoding)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this line is commented out. Can you remove it?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mcollina It's the signature of this method...probably less obvious than I thought

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it is nice to have the signature. I am not sure what the convention for this is in C++ though. Something similar to JSDoc might be nicer?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is more about the positions, the types are pretty obvious given the assertions below.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I do not have a strong opinion... hm... maybe change wrap.start to: Arguments: ?

void FSEventWrap::Start(const FunctionCallbackInfo<Value>& args) {
Environment* env = Environment::GetCurrent(args);

FSEventWrap* wrap;
ASSIGN_OR_RETURN_UNWRAP(&wrap, args.Holder());
if (wrap->initialized_)
return args.GetReturnValue().Set(0);
FSEventWrap* wrap = Unwrap<FSEventWrap>(args.Holder());
CHECK_NE(wrap, nullptr);
CHECK(!wrap->initialized_);

static const char kErrMsg[] = "filename must be a string or Buffer";
if (args.Length() < 1)
return env->ThrowTypeError(kErrMsg);
const int argc = args.Length();
CHECK_GE(argc, 4);

BufferValue path(env->isolate(), args[0]);
if (*path == nullptr)
return env->ThrowTypeError(kErrMsg);
CHECK_NE(*path, nullptr);

unsigned int flags = 0;
if (args[2]->IsTrue())
Expand All @@ -129,19 +148,21 @@ void FSEventWrap::Start(const FunctionCallbackInfo<Value>& args) {
wrap->encoding_ = ParseEncoding(env->isolate(), args[3], kDefaultEncoding);

int err = uv_fs_event_init(wrap->env()->event_loop(), &wrap->handle_);
if (err == 0) {
wrap->initialized_ = true;
if (err != 0) {
return args.GetReturnValue().Set(err);
}

err = uv_fs_event_start(&wrap->handle_, OnEvent, *path, flags);
err = uv_fs_event_start(&wrap->handle_, OnEvent, *path, flags);
wrap->initialized_ = true;

if (err == 0) {
// Check for persistent argument
if (!args[1]->IsTrue()) {
uv_unref(reinterpret_cast<uv_handle_t*>(&wrap->handle_));
}
} else {
FSEventWrap::Close(args);
}
if (err != 0) {
FSEventWrap::Close(args);
return args.GetReturnValue().Set(err);
}

// Check for persistent argument
if (!args[1]->IsTrue()) {
uv_unref(reinterpret_cast<uv_handle_t*>(&wrap->handle_));
}

args.GetReturnValue().Set(err);
Expand Down Expand Up @@ -209,13 +230,11 @@ void FSEventWrap::OnEvent(uv_fs_event_t* handle, const char* filename,


void FSEventWrap::Close(const FunctionCallbackInfo<Value>& args) {
FSEventWrap* wrap;
ASSIGN_OR_RETURN_UNWRAP(&wrap, args.Holder());
FSEventWrap* wrap = Unwrap<FSEventWrap>(args.Holder());
CHECK_NE(wrap, nullptr);
CHECK(wrap->initialized_);

if (wrap == nullptr || wrap->initialized_ == false)
return;
wrap->initialized_ = false;

HandleWrap::Close(args);
}

Expand Down
75 changes: 59 additions & 16 deletions test/parallel/test-fs-watch-enoent.js
Original file line number Diff line number Diff line change
@@ -1,21 +1,64 @@
'use strict';

// This verifies the error thrown by fs.watch.

const common = require('../common');
const assert = require('assert');
const fs = require('fs');
const tmpdir = require('../common/tmpdir');
const path = require('path');
const nonexistentFile = path.join(tmpdir.path, 'non-existent');
const uv = process.binding('uv');

tmpdir.refresh();

{
const validateError = (err) => {
assert.strictEqual(nonexistentFile, err.path);
assert.strictEqual(nonexistentFile, err.filename);
assert.strictEqual(err.syscall, 'watch');
if (err.code === 'ENOENT') {
assert.strictEqual(
err.message,
`ENOENT: no such file or directory, watch '${nonexistentFile}'`);
assert.strictEqual(err.errno, uv.UV_ENOENT);
assert.strictEqual(err.code, 'ENOENT');
} else { // AIX
assert.strictEqual(
err.message,
`ENODEV: no such device, watch '${nonexistentFile}'`);
assert.strictEqual(err.errno, uv.UV_ENODEV);
assert.strictEqual(err.code, 'ENODEV');
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@nodejs/platform-aix is this expected behaviour?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

--- a/test/parallel/test-fs-watch-enoent.js
+++ b/test/parallel/test-fs-watch-enoent.js
@@ -6,6 +6,7 @@ const fs = require('fs');
 assert.throws(function() {
   fs.watch('non-existent-file');
 }, function(err) {
+  console.log(err)
   assert(err);
   assert(/non-existent-file/.test(err));
   assert.strictEqual(err.filename, 'non-existent-file');

with a console.log in the callback of the existing code, this is what I get in AIX:

{ Error: watch non-existent-file ENODEV
    at FSWatcher.start (fs.js:1346:19)
    at Object.fs.watch (fs.js:1369:11)
    at /tmp/gireesh/node/test/parallel/test-fs-watch-enoent.js:7:6
    at getActual (assert.js:421:5)
    at Function.throws (assert.js:430:18)
    at Object.<anonymous> (/tmp/gireesh/node/test/parallel/test-fs-watch-enoent.js:6:8)
    at Module._compile (module.js:670:30)
    at Object.Module._extensions..js (module.js:681:10)
    at Module.load (module.js:581:32)
    at tryModuleLoad (module.js:521:12)
  errno: 'ENODEV',
  code: 'ENODEV',
  syscall: 'watch non-existent-file',
  filename: 'non-existent-file' }

hope this helps.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just tested with the changes from this PR on a local AIX box, and this test looks good - the error messages are matching, thanks!

}
return true;
};

assert.throws(
() => fs.watch(nonexistentFile, common.mustNotCall()),
validateError
);
}

{
const file = path.join(tmpdir.path, 'file-to-watch');
fs.writeFileSync(file, 'test');
const watcher = fs.watch(file, common.mustNotCall());

const validateError = (err) => {
assert.strictEqual(nonexistentFile, err.path);
assert.strictEqual(nonexistentFile, err.filename);
Copy link
Member

@BridgeAR BridgeAR Mar 6, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: the arguments should be the other way around. Otherwise the error message would be weird in case of an error. The same in other places.

assert.strictEqual(
err.message,
`ENOENT: no such file or directory, watch '${nonexistentFile}'`);
assert.strictEqual(err.errno, uv.UV_ENOENT);
assert.strictEqual(err.code, 'ENOENT');
assert.strictEqual(err.syscall, 'watch');
fs.unlinkSync(file);
return true;
};

watcher.on('error', common.mustCall(validateError));

assert.throws(function() {
fs.watch('non-existent-file');
}, function(err) {
assert(err);
assert(/non-existent-file/.test(err));
assert.strictEqual(err.filename, 'non-existent-file');
return true;
});

const watcher = fs.watch(__filename);
watcher.on('error', common.mustCall(function(err) {
assert(err);
assert(/non-existent-file/.test(err));
assert.strictEqual(err.filename, 'non-existent-file');
}));
watcher._handle.onchange(-1, 'ENOENT', 'non-existent-file');
// Simulate the invocation from the binding
watcher._handle.onchange(uv.UV_ENOENT, 'ENOENT', nonexistentFile);
}
22 changes: 20 additions & 2 deletions test/parallel/test-fs-watch.js
Original file line number Diff line number Diff line change
Expand Up @@ -65,10 +65,16 @@ for (const testCase of cases) {
assert.strictEqual(eventType, 'change');
assert.strictEqual(argFilename, testCase.fileName);

watcher.start(); // should not crash

common.expectsError(() => watcher.start(), {
code: 'ERR_FS_WATCHER_ALREADY_STARTED',
message: 'The watcher has already been started'
});
// end of test case
watcher.close();
common.expectsError(() => watcher.close(), {
code: 'ERR_FS_WATCHER_NOT_STARTED',
message: 'The watcher has not been started'
});
}));

// long content so it's actually flushed. toUpperCase so there's real change.
Expand All @@ -78,3 +84,15 @@ for (const testCase of cases) {
fs.writeFileSync(testCase.filePath, content2);
}, 100);
}

[false, 1, {}, [], null, undefined].forEach((i) => {
common.expectsError(
() => fs.watch(i, common.mustNotCall()),
{
code: 'ERR_INVALID_ARG_TYPE',
type: TypeError,
message: 'The "filename" argument must be one of ' +
'type string, Buffer, or URL'
}
);
});
7 changes: 5 additions & 2 deletions test/sequential/test-fs-watch.js
Original file line number Diff line number Diff line change
Expand Up @@ -112,12 +112,15 @@ tmpdir.refresh();
// https://github.com/joyent/node/issues/6690
{
let oldhandle;
assert.throws(function() {
assert.throws(() => {
const w = fs.watch(__filename, common.mustNotCall());
oldhandle = w._handle;
w._handle = { close: w._handle.close };
w.close();
}, /^TypeError: Illegal invocation$/);
}, {
message: 'handle must be a FSEvent',
code: 'ERR_ASSERTION'
});
oldhandle.close(); // clean up

assert.throws(function() {
Expand Down