Skip to content

Add Module Reloading On File Change #69

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

Merged
merged 13 commits into from
Mar 9, 2020
Merged

Add Module Reloading On File Change #69

merged 13 commits into from
Mar 9, 2020

Conversation

JeremyTCD
Copy link
Member

@JeremyTCD JeremyTCD commented Feb 3, 2020

Details: #69 (comment)

@rosslovas
Copy link

Come to think of it, restarting the server does nothing to in-memory stuff like previously loaded modules. We should just clear the NodeJS module cache (preserving NodeJS core modules like fs, path etc) on file change. This would be an instant clean reset for NodeJS.

This was my first idea but it was not an option for us because we have other fs.watch calls set up to accommodate for changing config files. In my code snippet in #67, when I clear require.cache instead of process.exit(0), after a while of normal usage in which I change my js files a few times, if I change the aforementioned config file once, the log shows that many fs.watch calls are still active, one for each time the module that sets them up was reloaded:

[09:52:47 INF Jering.Javascript.NodeJS.HttpNodeJSService] Removing cached settings as file changed
[09:52:47 INF Jering.Javascript.NodeJS.HttpNodeJSService] Removing cached settings as file changed
[09:52:47 INF Jering.Javascript.NodeJS.HttpNodeJSService] Removing cached settings as file changed
[09:52:47 INF Jering.Javascript.NodeJS.HttpNodeJSService] Removing cached settings as file changed
[09:52:47 INF Jering.Javascript.NodeJS.HttpNodeJSService] Removing cached settings as file changed
...

While the old module instances themselves should have been garbage collected, unfortunately it seems not everything gets cleaned up.

@JeremyTCD
Copy link
Member Author

JeremyTCD commented Feb 5, 2020

That's interesting. What if we force garbage collection after clearing the cache?

@JeremyTCD
Copy link
Member Author

Scratch that, I think the problem is that HttpServer.ts has a reference to the fs module export (along with some other built in module exports). Will have to invalidate all those references.

@rosslovas
Copy link

rosslovas commented Feb 6, 2020

Can confirm forcing garbage collection doesn't help.

A simpler way to demonstrate the problem is with setInterval e.g. a module with the following:

const now = Date.now();
setInterval(() => console.log(now), 5000);

It should only be possible to have one of those running at once, but clearing the module cache and reloading the module allows more and more to build up. There are probably plenty of other async operations that will cause the same problem.

Perhaps it's possible to track all async handles with async_hooks and destroy them all (edit: does not seem trival to do!) when you clear the module cache? I wonder if that covers everything...

@JeremyTCD
Copy link
Member Author

Ah okay, so the NodeJS runtime keeps direct references to callback methods and continues to call them when events trigger.

async_hooks

This is interesting. Read through quickly and it looks like we can't manually destroy AsyncResources though. Agreed, cleanly clearing all async-resources seems difficult.

I wonder if that covers everything...

Yeah, high risk of memory leaks.

We'll have to take a look at NodeJS' source code and perhaps do some memory profiling to figure out if we can reload modules cleanly.

If it isn't possible we'll just restart the process. Will have to figure out how to handle queued invocations that get canceled in that case. Am not satisfied with the current reliance on retries.

@JeremyTCD
Copy link
Member Author

JeremyTCD commented Feb 8, 2020

Module Reloading Approaches

At present, users can use NodeJS's fs to watch files and restart the process when they change, thereby reloading all modules. While this works, module reloading is more popular than expected, so we should provide an out-of-the-box solution. Two possibilities:

Delete Cached Modules

Delete modules from require.cache so they're reloaded:

By deleting a key value from this object, the next require will reload the module.

Pros

Fast. Avoids restarting the process, avoids terminating invocations midway through.

Cons

Memory leaks. Even if we delete a module from the cache, as pointed out by @rosslovas, the NodeJS runtime may hold references to the module. This is typically due to async stuff - the runtime holds references to callbacks, which in turn hold references to the modules that created them.

Did a quick check to see if NodeJS exposes the structures holding callbacks. Found several cases where it doesn't. E.g if you call setTimeout(callback, 100), an internal linked list L in internal/timers.js holds the callback. It's inaccessible.

How we can make this work

When we need to reload a module, we can call a custom method, e.g cleanup. Users would define their modules as follows:

module.exports = {
    doSomething: (callback) => {
        ... // Do something
        callback(null, result);
    },
    cleanup: () => {
        ... // Cleanup async stuff, e.g clearTimeout()
    }
}

HttpServer.ts would have the following logic:

fs.watch(watchDirectory, { recursive: watchRecursive }, (_, filename) => {
    var moduleID = ... // We can easily tell from within NodeJS if the changed file is a loaded module, its ID is just its absolute file path
    var module = require.cache[moduleID];
    if (typeof module.cleanup === 'function') {
        module.cleanup(); // Cleanup async stuff
    }
    delete require.cache[moduleID];
    ... // Optionally cleanup and delete its dependencies
});

As noted in the comments, we can easily tell if a .js file that changes is a loaded module, and automatically reload it. Users will have to associate other kinds of files with modules to reload though, e.g if x.html changes, reload module x.js.

Users who aren't using async stuff or are just using one-off callbacks like setTimeout might not need
to define cleanup. They have to be vigilant about libraries they depend on though, and also other situations apart from async where the runtime may hold on to references.

Restart Process

Nukes the entire process, effectively reloading all modules.

Pros

Easy to implement. Don't need to worry about memory leaks.

Cons

Slow. Restarting the process takes several hundred ms. We could start a new process while the previous one is cleaning up, though if a user changes files rapidly this could cause a bunch of NodeJS processes.

Unstable. If we restart the process immediately after a file changes, invocations may terminate midway through. Users have to be wary of this. Also, when an invocation fails because of a restart, the error is a generic SocketError so our logic has to use up a retry. If a user changes files often, all of an invocation's retries may be used up.

We could allow the previous process to complete while sending new requests to a new process. Again, if a user changes files rapidly this could cause a bunch of NodeJS processes.

Conclusion

Neither solution is clean. Deleting cached modules is much better for development situations, restarting the process is better for long running applications that don't update files often. Perhaps we can implement both solutions eventually, but which one to implement first? Suggestions welcome.

@JeremyTCD JeremyTCD changed the title Add File Watching Reload Modules On File Change Feb 8, 2020
@JeremyTCD JeremyTCD changed the title Reload Modules On File Change Add Module Reloading On File Change Feb 8, 2020
@dustinsoftware
Copy link
Contributor

dustinsoftware commented Feb 22, 2020

Since this is uncontrolled userland code that's being handled by this library, restarting the process seems like the simpler of the two options. You are correct that if any modules leak event listeners that there won't be a reliable way to detect them. There also could be child processes that are spawned (child_process.spawn), which should be cleaned up.

@JeremyTCD
Copy link
Member Author

@dustinsoftware Yeah agreed, slightly quicker module reloads aren't worth the extra complexity. I've implemented process restarting on file change, just got to clean up, test and document.

@JeremyTCD JeremyTCD force-pushed the Add_File_Watching branch 4 times, most recently from 4a76943 to 8e16b5c Compare February 25, 2020 07:14
- For process restarting to work, OutOfProcessNodeJSService will have to control multiple processes at once. This means it's output data and error data string builders are no longer sufficient - each process needs its own string builders. So we move all message building to NodeJSProcess.
- NodeJSProcess was implementing Object.Finalizer unecessarily. Under the hood, the Process type handles unmanaged resource disposal using a SafeHandle.
…ce.TryInvokeCoreAsync<T>

- When a file changes, we stop sending invocations to the last process, we create and connect to a new process and send invocations to it instead. For optimum responsiveness, we want to create an dconnect to the new process immediately after the file changes, not when the first invocation comes in after the file changes.
- To do that, we need connection logic with retries and exception handling in its own method.
- A serendipitous side effect is that we can add a ConnectionException specifically for connection issues.
@JeremyTCD JeremyTCD force-pushed the Add_File_Watching branch 2 times, most recently from 7c95ae9 to bb2a969 Compare March 7, 2020 13:24
- Only relevant to NodeJSServiceImplementations.
- Only relevant to out-of-process implementations.
@JeremyTCD JeremyTCD force-pushed the Add_File_Watching branch from bb2a969 to 3fecc21 Compare March 7, 2020 14:47
@codecov
Copy link

codecov bot commented Mar 7, 2020

Codecov Report

Merging #69 into master will decrease coverage by 0.5%.
The diff coverage is 96.06%.

Impacted file tree graph

@@            Coverage Diff             @@
##           master      #69      +/-   ##
==========================================
- Coverage   97.52%   97.02%   -0.51%     
==========================================
  Files          18       23       +5     
  Lines         566      807     +241     
==========================================
+ Hits          552      783     +231     
- Misses         14       24      +10
Impacted Files Coverage Δ
...mplementations/InvocationData/InvocationRequest.cs 100% <ø> (ø)
...lementations/InvocationData/InvocationException.cs 100% <ø> (ø)
...eImplementations/InvocationData/InvocationError.cs 100% <ø> (ø)
...ementations/OutOfProcess/Http/InvocationContent.cs 100% <ø> (ø) ⬆️
src/NodeJS/StaticNodeJSService.cs 100% <ø> (ø) ⬆️
...ions/OutOfProcess/Http/InvocationContentFactory.cs 100% <ø> (ø) ⬆️
...tations/OutOfProcess/Http/HttpNodeJSPoolService.cs 96.42% <ø> (ø) ⬆️
...s/OutOfProcess/OutOfProcessNodeJSServiceOptions.cs 100% <100%> (ø) ⬆️
src/NodeJS/Utils/FileWatcherFactory.cs 100% <100%> (ø)
...ementations/OutOfProcess/Http/HttpNodeJSService.cs 97.36% <100%> (+0.59%) ⬆️
... and 15 more

Continue to review full report at Codecov.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update 1e7e55c...21be74a. Read the comment docs.

@JeremyTCD JeremyTCD force-pushed the Add_File_Watching branch 4 times, most recently from 178f52f to 7ffb903 Compare March 8, 2020 11:31
@JeremyTCD JeremyTCD force-pushed the Add_File_Watching branch 3 times, most recently from a67e1c6 to d4f10e2 Compare March 9, 2020 03:31
@JeremyTCD JeremyTCD merged commit e6b2146 into master Mar 9, 2020
@JeremyTCD JeremyTCD deleted the Add_File_Watching branch March 9, 2020 09:47
@JeremyTCD
Copy link
Member Author

JeremyTCD commented Mar 9, 2020

🚀 5.4.0 released with file watching.


Enabling file watching:

services.Configure<OutOfProcessNodeJSOptions>(options => options.EnableFileWatching = true);

(General info on configuring options)

More file watching options

OutOfProcessNodeJSOptions (WatchPath, WatchSubdirectories, WatchFileNamePatterns, WatchGracefulShutdown).

Benchmarks

@dustinsoftware
Copy link
Contributor

This is great!! Thanks for shipping it.

@JeremyTCD JeremyTCD linked an issue Apr 2, 2020 that may be closed by this pull request
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Add support for WatchFileExtensions
3 participants