Merge pull request #11828 from atom/as-file-recovery-service

File Recovery Service
This commit is contained in:
Antonio Scandurra
2016-05-27 11:08:00 +02:00
13 changed files with 311 additions and 13 deletions

View File

@@ -27,7 +27,7 @@
"event-kit": "^1.5.0",
"find-parent-dir": "^0.3.0",
"first-mate": "^5.1.1",
"fs-plus": "^2.8.0",
"fs-plus": "2.9.1",
"fstream": "0.1.24",
"fuzzaldrin": "^2.1",
"git-utils": "^4.1.2",
@@ -55,6 +55,7 @@
"season": "^5.3",
"semver": "^4.3.3",
"service-hub": "^0.7.0",
"sinon": "1.17.4",
"source-map-support": "^0.3.2",
"temp": "0.8.1",
"text-buffer": "9.1.0",

View File

@@ -583,7 +583,7 @@ describe('GitRepositoryAsync', () => {
it('subscribes to all the serialized buffers in the project', async () => {
await atom.workspace.open('file.txt')
project2 = new Project({notificationManager: atom.notifications, packageManager: atom.packages, confirm: atom.confirm})
project2 = new Project({notificationManager: atom.notifications, packageManager: atom.packages, confirm: atom.confirm, applicationDelegate: atom.applicationDelegate})
project2.deserialize(atom.project.serialize({isUnloading: true}))
const repo = project2.getRepositories()[0].async

View File

@@ -356,7 +356,7 @@ describe "GitRepository", ->
atom.workspace.open('file.txt')
runs ->
project2 = new Project({notificationManager: atom.notifications, packageManager: atom.packages, confirm: atom.confirm})
project2 = new Project({notificationManager: atom.notifications, packageManager: atom.packages, confirm: atom.confirm, applicationDelegate: atom.applicationDelegate})
project2.deserialize(atom.project.serialize({isUnloading: false}))
buffer = project2.getBuffers()[0]

View File

@@ -0,0 +1,127 @@
'use babel'
import {dialog} from 'electron'
import FileRecoveryService from '../../src/main-process/file-recovery-service'
import temp from 'temp'
import fs from 'fs-plus'
import sinon from 'sinon'
describe("FileRecoveryService", () => {
let recoveryService, recoveryDirectory
beforeEach(() => {
recoveryDirectory = temp.mkdirSync()
recoveryService = new FileRecoveryService(recoveryDirectory)
})
describe("when no crash happens during a save", () => {
it("creates a recovery file and deletes it after saving", () => {
const mockWindow = {}
const filePath = temp.path()
fs.writeFileSync(filePath, "some content")
recoveryService.willSavePath(mockWindow, filePath)
assert.equal(fs.listTreeSync(recoveryDirectory).length, 1)
fs.writeFileSync(filePath, "changed")
recoveryService.didSavePath(mockWindow, filePath)
assert.equal(fs.listTreeSync(recoveryDirectory).length, 0)
assert.equal(fs.readFileSync(filePath, 'utf8'), "changed")
})
it("creates only one recovery file when many windows attempt to save the same file, deleting it when the last one finishes saving it", () => {
const mockWindow = {}
const anotherMockWindow = {}
const filePath = temp.path()
fs.writeFileSync(filePath, "some content")
recoveryService.willSavePath(mockWindow, filePath)
recoveryService.willSavePath(anotherMockWindow, filePath)
assert.equal(fs.listTreeSync(recoveryDirectory).length, 1)
fs.writeFileSync(filePath, "changed")
recoveryService.didSavePath(mockWindow, filePath)
assert.equal(fs.listTreeSync(recoveryDirectory).length, 1)
assert.equal(fs.readFileSync(filePath, 'utf8'), "changed")
recoveryService.didSavePath(anotherMockWindow, filePath)
assert.equal(fs.listTreeSync(recoveryDirectory).length, 0)
assert.equal(fs.readFileSync(filePath, 'utf8'), "changed")
})
})
describe("when a crash happens during a save", () => {
it("restores the created recovery file and deletes it", () => {
const mockWindow = {}
const filePath = temp.path()
fs.writeFileSync(filePath, "some content")
recoveryService.willSavePath(mockWindow, filePath)
assert.equal(fs.listTreeSync(recoveryDirectory).length, 1)
fs.writeFileSync(filePath, "changed")
recoveryService.didCrashWindow(mockWindow)
assert.equal(fs.listTreeSync(recoveryDirectory).length, 0)
assert.equal(fs.readFileSync(filePath, 'utf8'), "some content")
})
it("restores the created recovery file when many windows attempt to save the same file and one of them crashes", () => {
const mockWindow = {}
const anotherMockWindow = {}
const filePath = temp.path()
fs.writeFileSync(filePath, "A")
recoveryService.willSavePath(mockWindow, filePath)
fs.writeFileSync(filePath, "B")
recoveryService.willSavePath(anotherMockWindow, filePath)
assert.equal(fs.listTreeSync(recoveryDirectory).length, 1)
fs.writeFileSync(filePath, "C")
recoveryService.didCrashWindow(mockWindow)
assert.equal(fs.readFileSync(filePath, 'utf8'), "A")
assert.equal(fs.listTreeSync(recoveryDirectory).length, 0)
fs.writeFileSync(filePath, "D")
recoveryService.willSavePath(mockWindow, filePath)
fs.writeFileSync(filePath, "E")
recoveryService.willSavePath(anotherMockWindow, filePath)
assert.equal(fs.listTreeSync(recoveryDirectory).length, 1)
fs.writeFileSync(filePath, "F")
recoveryService.didCrashWindow(anotherMockWindow)
assert.equal(fs.readFileSync(filePath, 'utf8'), "D")
assert.equal(fs.listTreeSync(recoveryDirectory).length, 0)
})
it("emits a warning when a file can't be recovered", sinon.test(function () {
const mockWindow = {}
const filePath = temp.path()
fs.writeFileSync(filePath, "content")
fs.chmodSync(filePath, 0444)
let logs = []
this.stub(console, 'log', (message) => logs.push(message))
this.stub(dialog, 'showMessageBox')
recoveryService.willSavePath(mockWindow, filePath)
recoveryService.didCrashWindow(mockWindow)
let recoveryFiles = fs.listTreeSync(recoveryDirectory)
assert.equal(recoveryFiles.length, 1)
assert.equal(logs.length, 1)
assert.match(logs[0], new RegExp(filePath))
assert.match(logs[0], new RegExp(recoveryFiles[0]))
}))
})
it("doesn't create a recovery file when the file that's being saved doesn't exist yet", () => {
const mockWindow = {}
recoveryService.willSavePath(mockWindow, "a-file-that-doesnt-exist")
assert.equal(fs.listTreeSync(recoveryDirectory).length, 0)
recoveryService.didSavePath(mockWindow, "a-file-that-doesnt-exist")
assert.equal(fs.listTreeSync(recoveryDirectory).length, 0)
})
})

View File

@@ -112,6 +112,28 @@ describe "Project", ->
editor.saveAs(tempFile)
expect(atom.project.getPaths()[0]).toBe path.dirname(tempFile)
describe "before and after saving a buffer", ->
[buffer] = []
beforeEach ->
waitsForPromise ->
atom.project.bufferForPath(path.join(__dirname, 'fixtures', 'sample.js')).then (o) ->
buffer = o
buffer.retain()
afterEach ->
buffer.release()
it "emits save events on the main process", ->
spyOn(atom.project.applicationDelegate, 'emitDidSavePath')
spyOn(atom.project.applicationDelegate, 'emitWillSavePath')
buffer.save()
expect(atom.project.applicationDelegate.emitDidSavePath.calls.length).toBe(1)
expect(atom.project.applicationDelegate.emitDidSavePath).toHaveBeenCalledWith(buffer.getPath())
expect(atom.project.applicationDelegate.emitWillSavePath.calls.length).toBe(1)
expect(atom.project.applicationDelegate.emitWillSavePath).toHaveBeenCalledWith(buffer.getPath())
describe "when a watch error is thrown from the TextBuffer", ->
editor = null
beforeEach ->

View File

@@ -25,7 +25,7 @@ describe "Workspace", ->
projectState = atom.project.serialize({isUnloading: true})
atom.workspace.destroy()
atom.project.destroy()
atom.project = new Project({notificationManager: atom.notifications, packageManager: atom.packages, confirm: atom.confirm.bind(atom)})
atom.project = new Project({notificationManager: atom.notifications, packageManager: atom.packages, confirm: atom.confirm.bind(atom), applicationDelegate: atom.applicationDelegate})
atom.project.deserialize(projectState)
atom.workspace = new Workspace({
config: atom.config, project: atom.project, packageManager: atom.packages,

View File

@@ -266,3 +266,9 @@ class ApplicationDelegate
getAutoUpdateManagerErrorMessage: ->
ipcRenderer.sendSync('get-auto-update-manager-error')
emitWillSavePath: (path) ->
ipcRenderer.sendSync('will-save-path', path)
emitDidSavePath: (path) ->
ipcRenderer.sendSync('did-save-path', path)

View File

@@ -185,7 +185,7 @@ class AtomEnvironment extends Model
@clipboard = new Clipboard()
@project = new Project({notificationManager: @notifications, packageManager: @packages, @config})
@project = new Project({notificationManager: @notifications, packageManager: @packages, @config, @applicationDelegate})
@commandInstaller = new CommandInstaller(@getVersion(), @applicationDelegate)

View File

@@ -4,6 +4,7 @@ AtomProtocolHandler = require './atom-protocol-handler'
AutoUpdateManager = require './auto-update-manager'
StorageFolder = require '../storage-folder'
Config = require '../config'
FileRecoveryService = require './file-recovery-service'
ipcHelpers = require '../ipc-helpers'
{BrowserWindow, Menu, app, dialog, ipcMain, shell} = require 'electron'
fs = require 'fs-plus'
@@ -78,6 +79,7 @@ class AtomApplication
@autoUpdateManager = new AutoUpdateManager(@version, options.test, @resourcePath, @config)
@applicationMenu = new ApplicationMenu(@version, @autoUpdateManager)
@atomProtocolHandler = new AtomProtocolHandler(@resourcePath, @safeMode)
@fileRecoveryService = new FileRecoveryService(path.join(process.env.ATOM_HOME, "recovery"))
@listenForArgumentsFromNewProcess()
@setupJavaScriptArguments()
@@ -242,7 +244,7 @@ class AtomApplication
options.window = window
@openPaths(options)
else
new AtomWindow(options)
new AtomWindow(@fileRecoveryService, options)
else
@promptForPathToOpen('all', {window})
@@ -325,6 +327,14 @@ class AtomApplication
ipcMain.on 'get-auto-update-manager-error', (event) =>
event.returnValue = @autoUpdateManager.getErrorMessage()
ipcMain.on 'will-save-path', (event, path) =>
@fileRecoveryService.willSavePath(@windowForEvent(event), path)
event.returnValue = true
ipcMain.on 'did-save-path', (event, path) =>
@fileRecoveryService.didSavePath(@windowForEvent(event), path)
event.returnValue = true
setupDockMenu: ->
if process.platform is 'darwin'
dockMenu = Menu.buildFromTemplate [
@@ -485,7 +495,7 @@ class AtomApplication
windowInitializationScript ?= require.resolve('../initialize-application-window')
resourcePath ?= @resourcePath
windowDimensions ?= @getDimensionsForNewWindow()
openedWindow = new AtomWindow({initialPaths, locationsToOpen, windowInitializationScript, resourcePath, devMode, safeMode, windowDimensions, profileStartup, clearWindowState, env})
openedWindow = new AtomWindow(@fileRecoveryService, {initialPaths, locationsToOpen, windowInitializationScript, resourcePath, devMode, safeMode, windowDimensions, profileStartup, clearWindowState, env})
if pidToKillWhenClosed?
@pidsToOpenWindows[pidToKillWhenClosed] = openedWindow
@@ -564,7 +574,7 @@ class AtomApplication
packagePath = @packages.resolvePackagePath(packageName)
windowInitializationScript = path.resolve(packagePath, pack.urlMain)
windowDimensions = @getDimensionsForNewWindow()
new AtomWindow({windowInitializationScript, @resourcePath, devMode, safeMode, urlToOpen, windowDimensions, env})
new AtomWindow(@fileRecoveryService, {windowInitializationScript, @resourcePath, devMode, safeMode, urlToOpen, windowDimensions, env})
else
console.log "Package '#{pack.name}' does not have a url main: #{urlToOpen}"
else
@@ -609,7 +619,7 @@ class AtomApplication
devMode = true
isSpec = true
safeMode ?= false
new AtomWindow({windowInitializationScript, resourcePath, headless, isSpec, devMode, testRunnerPath, legacyTestRunnerPath, testPaths, logFile, safeMode, env})
new AtomWindow(@fileRecoveryService, {windowInitializationScript, resourcePath, headless, isSpec, devMode, testRunnerPath, legacyTestRunnerPath, testPaths, logFile, safeMode, env})
resolveTestRunnerPath: (testPath) ->
FindParentDir ?= require 'find-parent-dir'

View File

@@ -15,7 +15,7 @@ class AtomWindow
loaded: null
isSpec: null
constructor: (settings={}) ->
constructor: (@fileRecoveryService, settings={}) ->
{@resourcePath, initialPaths, pathToOpen, locationsToOpen, @isSpec, @headless, @safeMode, @devMode} = settings
locationsToOpen ?= [{pathToOpen}] if pathToOpen
locationsToOpen ?= []
@@ -125,6 +125,7 @@ class AtomWindow
global.atomApplication.saveState(false)
@browserWindow.on 'closed', =>
@fileRecoveryService.didCloseWindow(this)
global.atomApplication.removeWindow(this)
@browserWindow.on 'unresponsive', =>
@@ -140,6 +141,7 @@ class AtomWindow
@browserWindow.webContents.on 'crashed', =>
global.atomApplication.exit(100) if @headless
@fileRecoveryService.didCrashWindow(this)
chosen = dialog.showMessageBox @browserWindow,
type: 'warning'
buttons: ['Close Window', 'Reload', 'Keep It Open']

View File

@@ -0,0 +1,129 @@
'use babel'
import {dialog} from 'electron'
import crypto from 'crypto'
import Path from 'path'
import fs from 'fs-plus'
export default class FileRecoveryService {
constructor (recoveryDirectory) {
this.recoveryDirectory = recoveryDirectory
this.recoveryFilesByFilePath = new Map()
this.recoveryFilesByWindow = new WeakMap()
this.windowsByRecoveryFile = new Map()
}
willSavePath (window, path) {
if (!fs.existsSync(path)) return
const recoveryPath = Path.join(this.recoveryDirectory, RecoveryFile.fileNameForPath(path))
const recoveryFile =
this.recoveryFilesByFilePath.get(path) || new RecoveryFile(path, recoveryPath)
try {
recoveryFile.retain()
} catch (err) {
console.log(`Couldn't retain ${recoveryFile.recoveryPath}. Code: ${err.code}. Message: ${err.message}`)
return
}
if (!this.recoveryFilesByWindow.has(window)) {
this.recoveryFilesByWindow.set(window, new Set())
}
if (!this.windowsByRecoveryFile.has(recoveryFile)) {
this.windowsByRecoveryFile.set(recoveryFile, new Set())
}
this.recoveryFilesByWindow.get(window).add(recoveryFile)
this.windowsByRecoveryFile.get(recoveryFile).add(window)
this.recoveryFilesByFilePath.set(path, recoveryFile)
}
didSavePath (window, path) {
const recoveryFile = this.recoveryFilesByFilePath.get(path)
if (recoveryFile != null) {
try {
recoveryFile.release()
} catch (err) {
console.log(`Couldn't release ${recoveryFile.recoveryPath}. Code: ${err.code}. Message: ${err.message}`)
}
if (recoveryFile.isReleased()) this.recoveryFilesByFilePath.delete(path)
this.recoveryFilesByWindow.get(window).delete(recoveryFile)
this.windowsByRecoveryFile.get(recoveryFile).delete(window)
}
}
didCrashWindow (window) {
if (!this.recoveryFilesByWindow.has(window)) return
for (const recoveryFile of this.recoveryFilesByWindow.get(window)) {
try {
recoveryFile.recoverSync()
} catch (error) {
const message = 'A file that Atom was saving could be corrupted'
const detail =
`Error ${error.code}. There was a crash while saving "${recoveryFile.originalPath}", so this file might be blank or corrupted.\n` +
`Atom couldn't recover it automatically, but a recovery file has been saved at: "${recoveryFile.recoveryPath}".`
console.log(detail)
dialog.showMessageBox(window.browserWindow, {type: 'info', buttons: ['OK'], message, detail})
} finally {
for (let window of this.windowsByRecoveryFile.get(recoveryFile)) {
this.recoveryFilesByWindow.get(window).delete(recoveryFile)
}
this.windowsByRecoveryFile.delete(recoveryFile)
this.recoveryFilesByFilePath.delete(recoveryFile.originalPath)
}
}
}
didCloseWindow (window) {
if (!this.recoveryFilesByWindow.has(window)) return
for (let recoveryFile of this.recoveryFilesByWindow.get(window)) {
this.windowsByRecoveryFile.get(recoveryFile).delete(window)
}
this.recoveryFilesByWindow.delete(window)
}
}
class RecoveryFile {
static fileNameForPath (path) {
const extension = Path.extname(path)
const basename = Path.basename(path, extension).substring(0, 34)
const randomSuffix = crypto.randomBytes(3).toString('hex')
return `${basename}-${randomSuffix}${extension}`
}
constructor (originalPath, recoveryPath) {
this.originalPath = originalPath
this.recoveryPath = recoveryPath
this.refCount = 0
}
storeSync () {
fs.copyFileSync(this.originalPath, this.recoveryPath)
}
recoverSync () {
fs.copyFileSync(this.recoveryPath, this.originalPath)
this.removeSync()
}
removeSync () {
fs.unlinkSync(this.recoveryPath)
}
retain () {
if (this.isReleased()) this.storeSync()
this.refCount++
}
release () {
this.refCount--
if (this.isReleased()) this.removeSync()
}
isReleased () {
return this.refCount === 0
}
}

View File

@@ -21,7 +21,7 @@ class Project extends Model
Section: Construction and Destruction
###
constructor: ({@notificationManager, packageManager, config}) ->
constructor: ({@notificationManager, packageManager, config, @applicationDelegate}) ->
@emitter = new Emitter
@buffers = []
@paths = []
@@ -360,7 +360,6 @@ class Project extends Model
addBuffer: (buffer, options={}) ->
@addBufferAtIndex(buffer, @buffers.length, options)
@subscribeToBuffer(buffer)
addBufferAtIndex: (buffer, index, options={}) ->
@buffers.splice(index, 0, buffer)
@@ -390,6 +389,8 @@ class Project extends Model
@on 'buffer-created', (buffer) -> callback(buffer)
subscribeToBuffer: (buffer) ->
buffer.onWillSave ({path}) => @applicationDelegate.emitWillSavePath(path)
buffer.onDidSave ({path}) => @applicationDelegate.emitDidSavePath(path)
buffer.onDidDestroy => @removeBuffer(buffer)
buffer.onDidChangePath =>
unless @getPaths().length > 0

View File

@@ -124,7 +124,7 @@ class TextEditor extends Model
@softTabs, @firstVisibleScreenRow, @firstVisibleScreenColumn, initialLine, initialColumn, @tabLength,
@softWrapped, @decorationManager, @selectionsMarkerLayer, @buffer, suppressCursorCreation,
@mini, @placeholderText, lineNumberGutterVisible, @largeFileMode, @config, @clipboard, @grammarRegistry,
@assert, @applicationDelegate, grammar, @showInvisibles, @autoHeight, @scrollPastEnd, @editorWidthInChars,
@assert, grammar, @showInvisibles, @autoHeight, @scrollPastEnd, @editorWidthInChars,
@tokenizedBuffer, @ignoreInvisibles, @displayLayer
} = params