From 0c5674a56c29754b8f4fa281cc29c79ea1237a9d Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Fri, 23 Jun 2017 16:08:38 -0400 Subject: [PATCH] Split subtrees into child watchers on parent watcher removal --- spec/native-watcher-registry-spec.js | 28 +++-- src/native-watcher-registry.js | 177 +++++++++++++++++++-------- 2 files changed, 140 insertions(+), 65 deletions(-) diff --git a/spec/native-watcher-registry-spec.js b/spec/native-watcher-registry-spec.js index 107e0efd3..6efa6c219 100644 --- a/spec/native-watcher-registry-spec.js +++ b/spec/native-watcher-registry-spec.js @@ -17,9 +17,14 @@ class MockWatcher { return Promise.resolve(this.normalizedPath) } - attachToNative (native) { - this.native = native - this.native.attached.push(this) + attachToNative (native, nativePath) { + if (this.normalizedPath.startsWith(nativePath)) { + if (this.native) { + this.native.attached = this.native.attached.filter(each => each !== this) + } + this.native = native + this.native.attached.push(this) + } } } @@ -33,12 +38,10 @@ class MockNative { this.emitter = new Emitter() } - reattachTo (newNative) { + reattachTo (newNative, nativePath) { for (const watcher of this.attached) { - watcher.attachToNative(newNative) + watcher.attachToNative(newNative, nativePath) } - - this.attached = [] } onWillStop (callback) { @@ -196,7 +199,7 @@ describe('NativeWatcherRegistry', function () { STOPPED.stop() - const runningNode = registry.tree.lookup(runningPath).when({ + const runningNode = registry.tree.root.lookup(runningPath).when({ parent: node => node, missing: () => false, children: () => false @@ -204,7 +207,7 @@ describe('NativeWatcherRegistry', function () { expect(runningNode).toBeTruthy() expect(runningNode.getNativeWatcher()).toBe(RUNNING) - const stoppedNode = registry.tree.lookup(stoppedPath).when({ + const stoppedNode = registry.tree.root.lookup(stoppedPath).when({ parent: () => false, missing: () => true, children: () => false @@ -249,25 +252,24 @@ describe('NativeWatcherRegistry', function () { expect(childWatcher1.native).toBe(PARENT) // Stopping the parent should detach and recreate the child watchers. - // (Here, they'll be the same watcher instances used before, because of the fake createNative implementation.) PARENT.stop() expect(childWatcher0.native).toBe(CHILD0) expect(childWatcher1.native).toBe(CHILD1) - expect(registry.tree.lookup(['parent']).when({ + expect(registry.tree.root.lookup(['parent']).when({ parent: () => false, missing: () => false, children: () => true })).toBe(true) - expect(registry.tree.lookup(['parent', 'child0']).when({ + expect(registry.tree.root.lookup(['parent', 'child0']).when({ parent: () => true, missing: () => false, children: () => false })).toBe(true) - expect(registry.tree.lookup(['parent', 'child1']).when({ + expect(registry.tree.root.lookup(['parent', 'child1']).when({ parent: () => true, missing: () => false, children: () => false diff --git a/src/native-watcher-registry.js b/src/native-watcher-registry.js index 3d83bbf82..3ce127a85 100644 --- a/src/native-watcher-registry.js +++ b/src/native-watcher-registry.js @@ -2,6 +2,63 @@ import path from 'path' +class RegistryTree { + + constructor (basePathSegments, createNative) { + this.basePathSegments = basePathSegments + this.root = new RegistryNode() + this.createNative = createNative + } + + add (pathSegments, attachToNative) { + const absolutePathSegments = this.basePathSegments.concat(pathSegments) + const absolutePath = path.join(...absolutePathSegments) + + const attachToNew = (childPaths) => { + const native = this.createNative(absolutePath) + const leaf = new RegistryWatcherNode(native, absolutePathSegments, childPaths) + this.root = this.root.insert(pathSegments, leaf) + + const sub = native.onWillStop(() => { + sub.dispose() + this.root = this.root.remove(pathSegments, this.createNative) || new RegistryNode() + }) + + attachToNative(native, absolutePath) + return native + } + + this.root.lookup(pathSegments).when({ + parent: (parent, remaining) => { + // An existing NativeWatcher is watching the same directory or a parent directory of the requested path. + // Attach this Watcher to it as a filtering watcher and record it as a dependent child path. + const native = parent.getNativeWatcher() + parent.addChildPath(remaining) + attachToNative(native, path.join(...parent.getAbsolutePathSegments())) + }, + children: children => { + // One or more NativeWatchers exist on child directories of the requested path. Create a new native watcher + // on the parent directory, note the subscribed child paths, and cleanly stop the child native watchers. + const newNative = attachToNew(children.map(child => child.path)) + + for (let i = 0; i < children.length; i++) { + const childNode = children[i].node + const childNative = childNode.getNativeWatcher() + childNative.reattachTo(newNative, absolutePath) + childNative.dispose() + childNative.stop() + } + }, + missing: () => attachToNew([]) + }) + } + + getRoot () { + return this.root + } + +} + // Private: Non-leaf node in a tree used by the {NativeWatcherRegistry} to cover the allocated {Watcher} instances with // the most efficient set of {NativeWatcher} instances possible. Each {RegistryNode} maps to a directory in the // filesystem tree. @@ -57,10 +114,12 @@ class RegistryNode { // Private: Remove a {RegistryWatcherNode} by the exact watched directory. // // * `pathSegments` absolute pre-split filesystem path of the node to remove. + // * `createSplitNative` callback to be invoked with each child path segment {Array} if the {RegistryWatcherNode} + // is split into child watchers rather than removed outright. See {RegistryWatcherNode.remove}. // // Returns: The root of a new tree with the {RegistryWatcherNode} removed. Callers should replace their node // references with the returned value. - remove (pathSegments) { + remove (pathSegments, createSplitNative) { if (pathSegments.length === 0) { // Attempt to remove a path with child watchers. Do nothing. return this @@ -74,7 +133,7 @@ class RegistryNode { } // Recurse - const newChild = child.remove(pathSegments.slice(1)) + const newChild = child.remove(pathSegments.slice(1), createSplitNative) if (newChild === null) { delete this.children[pathKey] } else { @@ -95,7 +154,7 @@ class RegistryNode { leaves (prefix) { const results = [] for (const p of Object.keys(this.children)) { - results.push(...this.children[p].leaves(prefix + [p])) + results.push(...this.children[p].leaves(prefix.concat([p]))) } return results } @@ -108,11 +167,38 @@ class RegistryWatcherNode { // Private: Allocate a new node to track a {NativeWatcher}. // // * `nativeWatcher` An existing {NativeWatcher} instance. + // * `absolutePathSegments` The absolute path to this {NativeWatcher}'s directory as an {Array} of + // path segments. // * `childPaths` {Array} of child directories that are currently the responsibility of this - // {NativeWatcher}, if any - constructor (nativeWatcher, childPaths) { + // {NativeWatcher}, if any. Directories are represented as arrays of the path segments between this + // node's directory and the watched child path. + constructor (nativeWatcher, absolutePathSegments, childPaths) { this.nativeWatcher = nativeWatcher - this.childPaths = new Set(childPaths) + this.absolutePathSegments = absolutePathSegments + + // Store child paths as joined strings so they work as Set members. + this.childPaths = new Set() + for (let i = 0; i < childPaths.length; i++) { + this.childPaths.add(path.join(...childPaths[i])) + } + } + + // Private: Assume responsibility for a new child path. If this node is removed, it will instead + // split into a subtree with a new {RegistryWatcherNode} for each child path. + // + // * `childPathSegments` the {Array} of path segments between this node's directory and the watched + // child directory. + addChildPath (childPathSegments) { + this.childPaths.add(path.join(...childPathSegments)) + } + + // Private: Stop assuming responsbility for a previously assigned child path. If this node is + // removed, the named child path will no longer be allocated a {RegistryWatcherNode}. + // + // * `childPathSegments` the {Array} of path segments between this node's directory and the no longer + // watched child directory. + removeChildPath (childPathSegments) { + this.childPaths.delete(path.join(...childPathSegments)) } // Private: Accessor for the {NativeWatcher}. @@ -120,22 +206,47 @@ class RegistryWatcherNode { return this.nativeWatcher } + getAbsolutePathSegments () { + return this.absolutePathSegments + } + // Private: Identify how this watcher relates to a request to watch a directory tree. // // * `pathSegments` filesystem path of a new {Watcher} already split into an Array of directory names. - // + //g // Returns: A {ParentResult} referencing this node. lookup (pathSegments) { return new ParentResult(this, pathSegments) } - // Private: Remove this leaf node if the watcher's exact path matches. + // Private: Remove this leaf node if the watcher's exact path matches. If this node is covering additional + // {Watcher} instances on child paths, it will be split into a subtree. // // * `pathSegments` filesystem path of the node to remove. + // * `createSplitNative` callback invoked with each {Array} of absolute child path segments to create a native + // watcher on a subtree of this node. // - // Returns: {null} if the `pathSegments` are an exact match, {this} otherwise. - remove (pathSegments) { - return pathSegments.length === 0 ? null : this + // Returns: If `pathSegments` match this watcher's path exactly, returns `null` if this node has no `childPaths` + // or a new {RegistryNode} on a newly allocated subtree if it did. If `pathSegments` does not match the watcher's + // path, it's an attempt to remove a subnode that doesn't exist, so the remove call has no effect and returns + // `this` unaltered. + remove (pathSegments, createSplitNative) { + if (pathSegments.length !== 0) { + return this + } else if (this.childPaths.size > 0) { + let newSubTree = new RegistryTree(this.absolutePathSegments, createSplitNative) + + for (const childPath of this.childPaths) { + const childPathSegments = childPath.split(path.sep) + newSubTree.add(childPathSegments, (native, attachmentPath) => { + this.nativeWatcher.reattachTo(native, attachmentPath) + }) + } + + return newSubTree.getRoot() + } else { + return null + } } // Private: Discover this {RegistryWatcherNode} instance. @@ -234,8 +345,7 @@ export default class NativeWatcherRegistry { // * `createNative` {Function} that will be called with a normalized filesystem path to create a new native // filesystem watcher. constructor (createNative) { - this.tree = new RegistryNode() - this.createNative = createNative + this.tree = new RegistryTree([], createNative) } // Private: Attach a watcher to a directory, assigning it a {NativeWatcher}. If a suitable {NativeWatcher} already @@ -252,45 +362,8 @@ export default class NativeWatcherRegistry { const normalizedDirectory = await watcher.getNormalizedPathPromise() const pathSegments = normalizedDirectory.split(path.sep).filter(segment => segment.length > 0) - const attachToNew = (childPaths) => { - const native = this.createNative(normalizedDirectory) - const leaf = new RegistryWatcherNode(native, childPaths) - this.tree = this.tree.insert(pathSegments, leaf) - - const sub = native.onWillStop(() => { - this.tree = this.tree.remove(pathSegments) || new RegistryNode() - sub.dispose() - }) - - watcher.attachToNative(native, '') - - return native - } - - this.tree.lookup(pathSegments).when({ - parent: (parent, remaining) => { - // An existing NativeWatcher is watching a parent directory of the requested path. Attach this Watcher to - // it as a filtering watcher. - const native = parent.getNativeWatcher() - const subpath = remaining.length === 0 ? '' : path.join(...remaining) - - watcher.attachToNative(native, subpath) - }, - children: children => { - const newNative = attachToNew([]) - - // One or more NativeWatchers exist on child directories of the requested path. - for (let i = 0; i < children.length; i++) { - const child = children[i] - const childNative = child.getNativeWatcher() - childNative.reattachTo(newNative, normalizedDirectory) - childNative.dispose() - - // Don't await this Promise. Subscribers can listen for `onDidStop` to be notified if they choose. - childNative.stop() - } - }, - missing: () => attachToNew([]) + this.tree.add(pathSegments, (native, nativePath) => { + watcher.attachToNative(native, nativePath) }) } }