Skip to content

Commit ee9fab5

Browse files
Fixes #163; Created new windowMaid.js (#168)
* Fixes #163; Created new windowMaid.js and tested it to ensure more robust window destruction. Destroyed windows can remain in memory, so that needs to be inspected. Also allow clients to remove windows from the cache explicitly #163 * naming #163 * object literal instead of static functions #163
1 parent 7e49079 commit ee9fab5

File tree

3 files changed

+133
-43
lines changed

3 files changed

+133
-43
lines changed

lib/exportJob.js

Lines changed: 7 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,14 @@ const uuid = require('uuid')
1616
const debug = require('debug')
1717
const logger = debug('electronpdf:')
1818

19+
const WindowMaid = require('./windowMaid')
1920
const wargs = require('./args')
2021

2122
// CONSTANTS
2223
/** Used to calculate browser dimensions based on PDF size */
2324
const HTML_DPI = 96
2425
/** Interval for which to check for hung windows, in milliseconds */
2526
const HUNG_WINDOW_CLEANUP_INTERVAL = process.env.ELECTRONPDF_WINDOW_CLEANUP_INTERVAL || 1000 * 30 /* seconds */
26-
/** How long a window can remain open before it is terminated, in milliseconds */
27-
const HUNG_WINDOW_THRESHOLD = process.env.ELECTRONPDF_WINDOW_LIFE_THRESHOLD || 1000 * 60 * 5 /* minutes */
2827
/** Used to determine browser size using a Micron -> Inch -> Pixel conversion */
2928
const MICRONS_INCH_RATIO = 25400
3029
/** When a ready event option is set, this is the default timeout. It is overridden by the wait option */
@@ -41,45 +40,8 @@ const DEFAULT_OPTIONS = {
4140
inMemory: false
4241
}
4342

44-
// Window Cache - Keep track of all windows created, and if any get stuck close
45-
// them
46-
const windowCache = {}
47-
/**
48-
* When a job creates a window it invoks this method so any memory leaks
49-
* due to hung windows are prevented. This can happen if an uncaught exception
50-
* occurs and job.destroy() is never invoked.
51-
* @param job
52-
*/
53-
function registerOpenWindow (job) {
54-
const w = job.window
55-
windowCache[w.id] = {job: job, window: w, lastUsed: Date.now()}
56-
}
57-
/**
58-
* Anytime a window is used this function should be invoked to update
59-
* the lastUsed property in the window cache
60-
* @param id
61-
*/
62-
function touchWindow (id) {
63-
windowCache[id].lastUsed = Date.now()
64-
}
65-
66-
function cleanupHungWindows () {
67-
const now = Date.now()
68-
const hungWindows = _.filter(windowCache,
69-
e => now - e.lastUsed > HUNG_WINDOW_THRESHOLD)
70-
logger(`checking hung windows. total windows: ${_.size(windowCache)}`)
71-
_.forEach(hungWindows, e => {
72-
const windowContext = {
73-
id: e.window.id,
74-
lifespan: now - e.lastUsed
75-
}
76-
e.job.emit('window.termination', windowContext)
77-
delete windowCache[e.window.id]
78-
e.job.destroy()
79-
})
80-
}
81-
82-
setInterval(cleanupHungWindows, HUNG_WINDOW_CLEANUP_INTERVAL)
43+
// Use the maid to ensure we don't leak windows
44+
setInterval(WindowMaid.cleanupHungWindows, HUNG_WINDOW_CLEANUP_INTERVAL)
8345

8446
/**
8547
* A job should be created to process a given export opreation for one or more
@@ -143,7 +105,7 @@ class ExportJob extends EventEmitter {
143105
this.emit(`${RENDER_EVENT_PREFIX}start`)
144106
this._launchBrowserWindow()
145107
const win = this.window
146-
registerOpenWindow(this)
108+
WindowMaid.registerOpenWindow(this)
147109

148110
// TODO: Check for different domains, this is meant to support only a single origin
149111
const firstUrl = this.input[0]
@@ -227,6 +189,8 @@ class ExportJob extends EventEmitter {
227189
destroy () {
228190
if (this.window) {
229191
try {
192+
logger(`destroying job with window: ${this.window.id}`)
193+
WindowMaid.removeWindow(this.window.id)
230194
this.window.close()
231195
} finally {
232196
this.window = undefined
@@ -291,7 +255,7 @@ class ExportJob extends EventEmitter {
291255
* @private
292256
*/
293257
_initializeWindowForResource () {
294-
touchWindow(this.window.id)
258+
WindowMaid.touchWindow(this.window.id)
295259
// Reset the generated flag for each input URL because this same job/window
296260
// can be reused in this scenario
297261
this.generated = false

lib/windowMaid.js

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
// Third Party Modules
2+
const _ = require('lodash')
3+
4+
// Logging
5+
const debug = require('debug')
6+
const logger = debug('electronpdf:')
7+
8+
/** How long a window can remain open before it is terminated, in milliseconds */
9+
const HUNG_WINDOW_THRESHOLD = process.env.ELECTRONPDF_WINDOW_LIFE_THRESHOLD || 1000 * 60 * 5 /* minutes */
10+
11+
// Window Cache - Keep track of all windows created, and if any get stuck close
12+
// them
13+
const windowCache = {}
14+
15+
const windowMaid = {
16+
17+
/**
18+
* When a job creates a window it invokes this method so any memory leaks
19+
* due to hung windows are prevented. This can happen if an uncaught
20+
* exception occurs and job.destroy() is never invoked.
21+
* @param exportJob the ExportJob instance, it must have a window reference set
22+
*/
23+
registerOpenWindow (exportJob) {
24+
const w = exportJob.window
25+
windowCache[w.id] = {id: w.id, job: exportJob, window: w, lastUsed: Date.now()}
26+
},
27+
28+
/**
29+
* Anytime a window is used this function should be invoked to update
30+
* the lastUsed property in the window cache
31+
* @param id
32+
*/
33+
touchWindow (id) {
34+
windowCache[id].lastUsed = Date.now()
35+
},
36+
37+
/**
38+
* Allows a job to gracefully remove a window
39+
* @param id the window id
40+
*/
41+
removeWindow (id) {
42+
delete windowCache[id]
43+
},
44+
45+
/**
46+
* Checks every window that was registered to make sure it has been used within
47+
* the allowed threshold, and if not it is destroyed and removed from memory.
48+
*
49+
* @param {number} [threshold=process.env.ELECTRONPDF_WINDOW_LIFE_THRESHOLD]
50+
* the number of millseconds that a window has to be open and untouched for it to be destroyed.
51+
*/
52+
cleanupHungWindows (threshold) {
53+
const now = Date.now()
54+
const th = threshold || HUNG_WINDOW_THRESHOLD
55+
const hungWindows = _.filter(windowCache, e => now - e.lastUsed >= th)
56+
57+
logger(`checking hung windows-> ` +
58+
`total windows: ${_.size(windowCache)}, ` +
59+
`hung windows: ${_.size(hungWindows)}, ` +
60+
`threshold: ${th}`)
61+
62+
_.forEach(hungWindows, e => {
63+
logger('destroying hung window: ', e.id)
64+
const destroyable = e.job && e.window && !e.window.isDestroyed()
65+
if (destroyable) {
66+
const windowContext = {
67+
id: e.window.id,
68+
lifespan: now - e.lastUsed
69+
}
70+
e.job.emit('window.termination', windowContext)
71+
delete windowCache[e.id]
72+
e.job.destroy()
73+
} else {
74+
logger('Warning: a window was left in the cache that was already destroyed, do proper cleanup')
75+
delete windowCache[e.id]
76+
}
77+
})
78+
},
79+
80+
windowCount () {
81+
return _.size(windowCache)
82+
}
83+
84+
}
85+
86+
module.exports = windowMaid

test/windowMaid-test.js

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { test } from 'ava'
2+
3+
import WindowMaid from '../lib/windowMaid'
4+
5+
test('_cleanupHungWindows cleans up non destroyed window', t => {
6+
// Used to fail the test if emit() and destroy() are not invoked
7+
t.plan(3)
8+
9+
let job = {
10+
id: 'jobId',
11+
window: {id: 1, isDestroyed: () => false},
12+
emit: (event, context) => {
13+
t.is(event, 'window.termination')
14+
},
15+
destroy: () => {
16+
t.pass('we expected destruction and it happened!')
17+
}
18+
}
19+
20+
WindowMaid.registerOpenWindow(job)
21+
WindowMaid.cleanupHungWindows(-1)
22+
t.is(WindowMaid.windowCount(), 0)
23+
})
24+
25+
test('_cleanupHungWindows handles destroyed window', t => {
26+
let job = {
27+
id: 'jobId',
28+
window: {id: 1, isDestroyed: () => true},
29+
emit: (event, context) => {
30+
t.fail('no events were expected')
31+
},
32+
destroy: () => {
33+
t.fail('Calling destroy on an already destroyed window is not allowed')
34+
}
35+
}
36+
37+
WindowMaid.registerOpenWindow(job)
38+
WindowMaid.cleanupHungWindows(-1)
39+
t.is(WindowMaid.windowCount(), 0)
40+
})

0 commit comments

Comments
 (0)