Skip to content

Commit

Permalink
feat(cli): stash changes before reload to prevent crash
Browse files Browse the repository at this point in the history
  • Loading branch information
shigma committed Apr 14, 2021
1 parent 753c560 commit 3d4ebb8
Show file tree
Hide file tree
Showing 2 changed files with 91 additions and 39 deletions.
5 changes: 4 additions & 1 deletion packages/koishi/src/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -306,7 +306,10 @@ async function writeConfig(config: any, path: string, type: SourceType) {
if (type === 'js') {
output = '// ' + rootComment + '\nmodule.exports = ' + output
} else if (type === 'ts') {
output = '// ' + rootComment + '\nexport default ' + output
output = "import { AppConfig } from 'koishi'\n\n// "
+ rootComment
+ '\nexport default '
+ output.replace(/\n$/, ' as AppConfig\n')
}

// write to file
Expand Down
125 changes: 87 additions & 38 deletions packages/koishi/src/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,13 +149,13 @@ app.command('exit', '停止机器人运行', { authority: 4 })
})

// load plugins
const pluginMap = new Map<string, [name: string, options: any]>()
const plugins = new Set<string>()
const pluginEntries: [string, any?][] = Array.isArray(config.plugins)
? config.plugins.map(item => Array.isArray(item) ? item : [item])
: Object.entries(config.plugins || {})
for (const [name, options] of pluginEntries) {
const [path, plugin] = loadEcosystem('plugin', name)
pluginMap.set(require.resolve(path), [name, options])
plugins.add(require.resolve(path))
app.plugin(plugin, options)
}

Expand All @@ -179,18 +179,14 @@ app.start().then(() => {
createWatcher()
}, handleException)

interface MapOrSet<T> {
has(value: T): boolean
}

function loadDependencies(filename: string, ignored: MapOrSet<string>) {
function loadDependencies(filename: string, ignored: Set<string>) {
const dependencies = new Set<string>()
function loadModule({ filename, children }: NodeModule) {
function traverse({ filename, children }: NodeModule) {
if (ignored.has(filename) || dependencies.has(filename) || filename.includes('/node_modules/')) return
dependencies.add(filename)
children.forEach(loadModule)
children.forEach(traverse)
}
loadModule(require.cache[filename])
traverse(require.cache[filename])
return dependencies
}

Expand All @@ -199,33 +195,69 @@ function createWatcher() {

const { root = '', ignored = [], fullReload } = config.watch || {}
const watchRoot = resolve(cwd, process.env.KOISHI_WATCH_ROOT ?? root)
const externals = loadDependencies(__filename, pluginMap)
const watcher = watch(watchRoot, {
...config.watch,
ignored: ['**/node_modules/**', '**/.git/**', ...ignored],
})

/**
* changes from externals E will always trigger a full reload
*
* - root R -> external E -> none of plugin Q
*/
const externals = loadDependencies(__filename, plugins)

const logger = new Logger('app:watcher')
function triggerFullReload() {
if (!fullReload) return
logger.info('trigger full reload')
process.exit(114)
}

watcher.on('change', async (path) => {
if (!require.cache[path]) return
logger.debug('change detected:', path)

if (externals.has(path)) return triggerFullReload()
/**
* files X that should not be marked as declined
*
* - including all changes C
* - some change C -> file X -> some change D
*/
let stashed = new Set<string>()
let currentUpdate: Promise<void>

function flushChanges() {
const tasks: Promise<void>[] = []
const reloads: [plugin: Plugin, state: Plugin.State, name: string][] = []

/** files that should be reloaded */
const reloads: [filename: string, state: Plugin.State][] = []

/**
* files X that should be reloaded
*
* - some plugin P -> file X -> some change C
* - file X -> none of plugin Q -> some change D
*/
const accepted = new Set<string>()
/** files that should not be reloaded */
const declined = new Set([...externals, ...loadDependencies(path, externals)])
declined.delete(path)

/**
* files X that should not be reloaded
*
* - including all externals E
* - some change C -> file X
* - file X -> none of change D
*/
const declined = new Set(externals)

function traverse(filename: string) {
if (externals.has(filename) || filename.includes('/node_modules/')) return
const { children } = require.cache[filename]
let isActive = stashed.has(filename)
for (const module of children) {
if (traverse(module.filename)) {
stashed.add(filename)
isActive = true
}
}
if (isActive) return isActive
declined.add(filename)
}
Array.from(stashed).forEach(traverse)

for (const filename in require.cache) {
// we only detect reloads at plugin level
Expand All @@ -234,8 +266,8 @@ function createWatcher() {
if (!state) continue

// check if it is a dependent of the changed file
const dependencies = loadDependencies(filename, declined)
if (!dependencies.has(path)) continue
const dependencies = [...loadDependencies(filename, declined)]
if (!dependencies.some(dep => stashed.has(dep))) continue

// accept dependencies to be reloaded
dependencies.forEach(dep => accepted.add(dep))
Expand All @@ -247,26 +279,43 @@ function createWatcher() {

// dispose installed plugin
const displayName = plugin.name || relative(watchRoot, filename)
reloads.push([module.exports, state, displayName])
reloads.push([filename, state])
tasks.push(app.dispose(plugin).catch((err) => {
logger.warn('failed to dispose plugin %c\n' + coerce(err), displayName)
}))
}

await Promise.all(tasks)

accepted.forEach((path) => {
logger.debug('cache deleted:', path)
delete require.cache[path]
stashed = new Set()
currentUpdate = Promise.all(tasks).then(() => {
// delete module cache before re-require
accepted.forEach((path) => {
logger.debug('cache deleted:', path)
delete require.cache[path]
})

// reload all dependent plugins
for (const [filename, state] of reloads) {
try {
const plugin = require(filename)
state.context.plugin(plugin, state.config)
const displayName = plugin.name || relative(watchRoot, filename)
logger.info('reload plugin %c', displayName)
} catch (err) {
logger.warn('failed to reload plugin at %c\n' + coerce(err), relative(watchRoot, filename))
}
}
})
}

for (const [plugin, state, displayName] of reloads) {
try {
state.context.plugin(plugin, state.config)
logger.info('reload plugin %c', displayName)
} catch (err) {
logger.warn('failed to reload plugin %c\n' + coerce(err), displayName)
}
}
watcher.on('change', (path) => {
if (!require.cache[path]) return
logger.debug('change detected:', path)

// files independent from any plugins will trigger a full reload
if (externals.has(path)) return triggerFullReload()

// do not trigger another reload during one reload
stashed.add(path)
Promise.resolve(currentUpdate).then(flushChanges)
})
}

0 comments on commit 3d4ebb8

Please sign in to comment.