Gracefully running Node as an NSTask in Cocoa
12th April, 2015 —
Update: 15th April, 2015
OK, this is weird: on further testing, it looks like I was confused about what was actually happening. It seems that (at least with the latest Xcode 6.3), the NSTask is killed properly when the Cocoa app terminates (even when stopped from within Xcode). However, the Node app itself spawns a child process for Pulse (the Go-based synchronisation engine), and it was that that wasn’t being cleaned up when the Cocoa app crashed or was stopped from within Xcode.
It seems, however, that monitoring the parent process and running cleanup (including killing Pulse, by sending it a SIGTERM) is the only reliable way of killing that child process. So, while the below should not be necessary for NSTasks, it is still useful for running cleanup of child tasks launched from your child taks.
Finally, I also added handlers in Node to call the clean up method for the various termination signals:
CoffeeScript: process.on 'exit', (code) =>
@cleanUp()
process.on 'uncaughtException', (code) =>
cleanUp()
process.on 'SIGINT', (code) =>
@cleanUp()
process.on 'SIGTERM', (code) =>
@cleanUp()
Graceful termination is hard to do
You’d think that tasks created by your Cocoa app would automatically be terminated by the system when your app crashes or is killed from Xcode.
You’d be wrong. (See update.)
This tripped me up and apparently I’m not the only one (see this, this, and this, etc.)
Instead of gracefully terminating, if an NSTask is running when your app crashes (or is stopped from Xcode), the task will dangle. This zombie task is a memory and CPU leak and might cause your app to misbehave or crash the next time it is run.
What doesn’t work
Before showing you what does work, here (in hopes of saving you time) are a couple of things I tried that don’t work:
- Killing the task in your App Delegate’s
applicationWillTerminate:
does not work as this is not called when the app is about to crash or when you stop it from Xcode. - Listening for unhandled exception calls doesn’t work. In my Swift project I couldn’t even get the handler called. (See this for how to use
NSSetUncaughtExceptionHandler
in Swift.) - You can’t get a list of all running processes from Cocoa and then terminate the one you want (
NSRunningApplication
only returns the running Cocoa apps).
What does work
Download source (GracefulNodeTask.zip, ~12MB)
The approach I finally took was based on the one used in the unfortunately-named Infanticide example by Alex Gray:
If we can’t monitor the child process and kill it, the child process can monitor the parent and kill itself when the parent dies. (This whole business is starting to sound rather more macarbre than I intended it to.)
I created a simple standalone example app that shows you how to run a Node.js app as an NSTask from within a sandboxed Cocoa app in a manner that gracefully handles the crashing or killing of the Cocoa app.
Let’s take a look at the relevant bits of the code to understand how it works.
Cocoa
First off, in the applicationDidFinishLaunching
handler, we set up the paths we need for configuring the task:
Swift let mainBundle = NSBundle.mainBundle()
let pathToNodeApp = mainBundle.pathForResource("boot", ofType: "js", inDirectory: "js")!
let pathToNode = mainBundle.pathForResource("node", ofType: "")!
let pathToAppFolder = pathToNodeApp.stringByDeletingLastPathComponent
Then, we make a note of our own process ID:
Swift: let processIdentifier = NSProcessInfo.processInfo().processIdentifier
And, finally, we pass the process ID to the Node.js app as a commandline argument while creating the NSTask so that it knows which process to monitor:
Swift: self.nodeTask = NSTask()
self.nodeTask!.currentDirectoryPath = pathToAppFolder
self.nodeTask!.launchPath = pathToNode
self.nodeTask!.arguments = [pathToNodeApp, "\(processIdentifier)"]
self.nodeTask!.standardOutput.fileHandleForReading.readInBackgroundAndNotify()
self.nodeTask!.launch()
Those are the relevant bits on the Cocoa side of things.
(The sample project does a little bit more, like piping standard output from Node to the Cocoa app, etc., so do download the source and have a peek.)
Node
On the Node side of things, everything is inside a folder called js.
When dragging Node.js source folders like this into your Cocoa projects, make sure that the Copy items if needed and Create folder references options are checked to ensure that the folder hierarchy of your Node app is maintained.
Note also that I’ve dragged the Node.js binary into the project and that’s where we’re running it from.
As using CoffeeScript for this example, I’ve added it to our package.json.
We’re also using a module called is-running
that checks whether a process is running in a manner that is compatible with Cocoa app sandboxing.
package.json {
…
"dependencies": {
"coffee-script": "1.9.1",
"is-running": "1.0.5"
}
}
The boot.js script is the entrypoint of our app. It registers CoffeeScript and includes index.coffee:
boot.js: require('coffee-script/register');
require('./index.coffee');
In index.coffee, we first store the process ID passed from Cocoa:
CoffeeScript: parentProcessID = parseInt(process.argv[2])
Then., we start polling every second to see if the parent process is still alive. When the parent process dies, we gracefully terminate the Node process also:
CoffeeScript: parentProcessHealthCheckerIntervalID = setInterval ->
isProcessRunning parentProcessID, (err, parentIsRunning) ->
if err
throw err
if !parentIsRunning
console.log 'Parent process died. Exiting gracefully…'
@cleanUp()
exit 0
, 1000
And that’s it.
Within a second of the parent process crashing or being otherwise killed, the Node process will gracefully shut itself down.
It took me a while to find this solution so I hope that it saves you some time in case you have the same problem.