diff --git a/lib/gui/modules/child-writer.js b/lib/gui/modules/child-writer.js deleted file mode 100644 index 7463f933..00000000 --- a/lib/gui/modules/child-writer.js +++ /dev/null @@ -1,244 +0,0 @@ -/* - * Copyright 2017 balena.io - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -'use strict' - -const Bluebird = require('bluebird') -const _ = require('lodash') -const ipc = require('node-ipc') -const sdk = require('etcher-sdk') -// eslint-disable-next-line node/no-missing-require -const EXIT_CODES = require('../../shared/exit-codes') -// eslint-disable-next-line node/no-missing-require -const errors = require('../../shared/errors') - -ipc.config.id = process.env.IPC_CLIENT_ID -ipc.config.socketRoot = process.env.IPC_SOCKET_ROOT - -// NOTE: Ensure this isn't disabled, as it will cause -// the stdout maxBuffer size to be exceeded when flashing -ipc.config.silent = true - -// > If set to 0, the client will NOT try to reconnect. -// See https://github.com/RIAEvangelist/node-ipc/ -// -// The purpose behind this change is for this process -// to emit a "disconnect" event as soon as the GUI -// process is closed, so we can kill this process as well. -ipc.config.stopRetrying = 0 - -const DISCONNECT_DELAY = 100 -const IPC_SERVER_ID = process.env.IPC_SERVER_ID - -/** - * @summary Send a log debug message to the IPC server - * @function - * @private - * - * @param {String} message - message - * - * @example - * log('Hello world!') - */ -const log = (message) => { - ipc.of[IPC_SERVER_ID].emit('log', message) -} - -/** - * @summary Terminate the child writer process - * @function - * @private - * - * @param {Number} [code=0] - exit code - * - * @example - * terminate(1) - */ -const terminate = (code) => { - ipc.disconnect(IPC_SERVER_ID) - process.nextTick(() => { - process.exit(code || EXIT_CODES.SUCCESS) - }) -} - -/** - * @summary Handle a child writer error - * @function - * @private - * - * @param {Error} error - error - * - * @example - * handleError(new Error('Something bad happened!')) - */ -const handleError = async (error) => { - ipc.of[IPC_SERVER_ID].emit('error', errors.toJSON(error)) - await Bluebird.delay(DISCONNECT_DELAY) - terminate(EXIT_CODES.GENERAL_ERROR) -} - -/** - * @summary writes the source to the destinations and valiates the writes - * @param {SourceDestination} source - source - * @param {SourceDestination[]} destinations - destinations - * @param {Boolean} verify - whether to validate the writes or not - * @param {Boolean} trim - whether to trim ext partitions before writing - * @param {Function} onProgress - function to call on progress - * @param {Function} onFail - function to call on fail - * @returns {Promise<{ bytesWritten, devices, errors} >} - * - * @example - * writeAndValidate(source, destinations, verify, onProgress, onFail, onFinish, onError) - */ -const writeAndValidate = async (source, destinations, verify, trim, onProgress, onFail) => { - let innerSource = await source.getInnerSource() - if (trim && (await innerSource.canRead())) { - innerSource = new sdk.sourceDestination.ConfiguredSource( - innerSource, - trim, - - // Create stream from file-disk (not source stream) - true - ) - } - const { failures, bytesWritten } = await sdk.multiWrite.pipeSourceToDestinations( - innerSource, - destinations, - onFail, - onProgress, - verify - ) - const result = { - bytesWritten, - devices: { - failed: failures.size, - successful: destinations.length - failures.size - }, - errors: [] - } - for (const [ destination, error ] of failures) { - error.device = destination.drive.device - result.errors.push(error) - } - return result -} - -ipc.connectTo(IPC_SERVER_ID, () => { - process.once('uncaughtException', handleError) - - // Gracefully exit on the following cases. If the parent - // process detects that child exit successfully but - // no flashing information is available, then it will - // assume that the child died halfway through. - - process.once('SIGINT', () => { - terminate(EXIT_CODES.SUCCESS) - }) - - process.once('SIGTERM', () => { - terminate(EXIT_CODES.SUCCESS) - }) - - // The IPC server failed. Abort. - ipc.of[IPC_SERVER_ID].on('error', () => { - terminate(EXIT_CODES.SUCCESS) - }) - - // The IPC server was disconnected. Abort. - ipc.of[IPC_SERVER_ID].on('disconnect', () => { - terminate(EXIT_CODES.SUCCESS) - }) - - ipc.of[IPC_SERVER_ID].on('write', async (options) => { - /** - * @summary Progress handler - * @param {Object} state - progress state - * @example - * writer.on('progress', onProgress) - */ - const onProgress = (state) => { - ipc.of[IPC_SERVER_ID].emit('state', state) - } - - let exitCode = EXIT_CODES.SUCCESS - - /** - * @summary Abort handler - * @example - * writer.on('abort', onAbort) - */ - const onAbort = async () => { - log('Abort') - ipc.of[IPC_SERVER_ID].emit('abort') - await Bluebird.delay(DISCONNECT_DELAY) - terminate(exitCode) - } - - ipc.of[IPC_SERVER_ID].on('cancel', onAbort) - - /** - * @summary Failure handler (non-fatal errors) - * @param {SourceDestination} destination - destination - * @param {Error} error - error - * @example - * writer.on('fail', onFail) - */ - const onFail = (destination, error) => { - ipc.of[IPC_SERVER_ID].emit('fail', { - // TODO: device should be destination - device: destination.drive, - error: errors.toJSON(error) - }) - } - - const destinations = _.map(options.destinations, 'device') - log(`Image: ${options.imagePath}`) - log(`Devices: ${destinations.join(', ')}`) - log(`Umount on success: ${options.unmountOnSuccess}`) - log(`Validate on success: ${options.validateWriteOnSuccess}`) - log(`Trim: ${options.trim}`) - const dests = _.map(options.destinations, (destination) => { - return new sdk.sourceDestination.BlockDevice(destination, options.unmountOnSuccess) - }) - const source = new sdk.sourceDestination.File(options.imagePath, sdk.sourceDestination.File.OpenFlags.Read) - try { - const results = await writeAndValidate( - source, - dests, - options.validateWriteOnSuccess, - options.trim, - onProgress, - onFail - ) - log(`Finish: ${results.bytesWritten}`) - results.errors = _.map(results.errors, (error) => { - return errors.toJSON(error) - }) - ipc.of[IPC_SERVER_ID].emit('done', { results }) - await Bluebird.delay(DISCONNECT_DELAY) - terminate(exitCode) - } catch (error) { - log(`Error: ${error.message}`) - exitCode = EXIT_CODES.GENERAL_ERROR - ipc.of[IPC_SERVER_ID].emit('error', errors.toJSON(error)) - } - }) - - ipc.of[IPC_SERVER_ID].on('connect', () => { - log(`Successfully connected to IPC server: ${IPC_SERVER_ID}, socket root ${ipc.config.socketRoot}`) - ipc.of[IPC_SERVER_ID].emit('ready', {}) - }) -}) diff --git a/lib/gui/modules/child-writer.ts b/lib/gui/modules/child-writer.ts new file mode 100644 index 00000000..4d3c4413 --- /dev/null +++ b/lib/gui/modules/child-writer.ts @@ -0,0 +1,260 @@ +/* + * Copyright 2017 balena.io + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { delay } from 'bluebird'; +import { Drive as DrivelistDrive } from 'drivelist'; +import * as sdk from 'etcher-sdk'; +import * as _ from 'lodash'; +import * as ipc from 'node-ipc'; + +import { toJSON } from '../../shared/errors'; +import { GENERAL_ERROR, SUCCESS } from '../../shared/exit-codes'; + +ipc.config.id = process.env.IPC_CLIENT_ID as string; +ipc.config.socketRoot = process.env.IPC_SOCKET_ROOT as string; + +// NOTE: Ensure this isn't disabled, as it will cause +// the stdout maxBuffer size to be exceeded when flashing +ipc.config.silent = true; + +// > If set to 0, the client will NOT try to reconnect. +// See https://github.com/RIAEvangelist/node-ipc/ +// +// The purpose behind this change is for this process +// to emit a "disconnect" event as soon as the GUI +// process is closed, so we can kill this process as well. +// @ts-ignore (0 is a valid value for stopRetrying and is not the same as false) +ipc.config.stopRetrying = 0; + +const DISCONNECT_DELAY = 100; +const IPC_SERVER_ID = process.env.IPC_SERVER_ID as string; + +/** + * @summary Send a log debug message to the IPC server + */ +function log(message: string) { + ipc.of[IPC_SERVER_ID].emit('log', message); +} + +/** + * @summary Terminate the child writer process + */ +function terminate(exitCode: number) { + ipc.disconnect(IPC_SERVER_ID); + process.nextTick(() => { + process.exit(exitCode || SUCCESS); + }); +} + +/** + * @summary Handle a child writer error + */ +async function handleError(error: Error) { + ipc.of[IPC_SERVER_ID].emit('error', toJSON(error)); + await delay(DISCONNECT_DELAY); + terminate(GENERAL_ERROR); +} + +interface WriteResult { + bytesWritten: number; + devices: { + failed: number; + successful: number; + }; + errors: Array; +} + +/** + * @summary writes the source to the destinations and valiates the writes + * @param {SourceDestination} source - source + * @param {SourceDestination[]} destinations - destinations + * @param {Boolean} verify - whether to validate the writes or not + * @param {Boolean} trim - whether to trim ext partitions before writing + * @param {Function} onProgress - function to call on progress + * @param {Function} onFail - function to call on fail + * @returns {Promise<{ bytesWritten, devices, errors} >} + */ +async function writeAndValidate( + source: sdk.sourceDestination.SourceDestination, + destinations: sdk.sourceDestination.BlockDevice[], + verify: boolean, + trim: boolean, + onProgress: sdk.multiWrite.OnProgressFunction, + onFail: sdk.multiWrite.OnFailFunction, +): Promise { + let innerSource: sdk.sourceDestination.SourceDestination = await source.getInnerSource(); + if (trim && (await innerSource.canRead())) { + // @ts-ignore FIXME: ts thinks that SparseReadStream can't be assigned to SparseReadable (which it implements) + innerSource = new sdk.sourceDestination.ConfiguredSource( + innerSource, + trim, + // Create stream from file-disk (not source stream) + true, + ); + } + const { + failures, + bytesWritten, + } = await sdk.multiWrite.pipeSourceToDestinations( + innerSource, + // @ts-ignore FIXME: ts thinks that BlockWriteStream can't be assigned to WritableStream (which it implements) + destinations, + onFail, + onProgress, + verify, + ); + const result: WriteResult = { + bytesWritten, + devices: { + failed: failures.size, + successful: destinations.length - failures.size, + }, + errors: [], + }; + for (const [destination, error] of failures) { + (error as (Error & { device: string })).device = destination.drive.device; + result.errors.push(error); + } + return result; +} + +interface WriteOptions { + imagePath: string; + destinations: DrivelistDrive[]; + unmountOnSuccess: boolean; + validateWriteOnSuccess: boolean; + trim: boolean; +} + +ipc.connectTo(IPC_SERVER_ID, () => { + process.once('uncaughtException', handleError); + + // Gracefully exit on the following cases. If the parent + // process detects that child exit successfully but + // no flashing information is available, then it will + // assume that the child died halfway through. + + process.once('SIGINT', () => { + terminate(SUCCESS); + }); + + process.once('SIGTERM', () => { + terminate(SUCCESS); + }); + + // The IPC server failed. Abort. + ipc.of[IPC_SERVER_ID].on('error', () => { + terminate(SUCCESS); + }); + + // The IPC server was disconnected. Abort. + ipc.of[IPC_SERVER_ID].on('disconnect', () => { + terminate(SUCCESS); + }); + + ipc.of[IPC_SERVER_ID].on('write', async (options: WriteOptions) => { + /** + * @summary Progress handler + * @param {Object} state - progress state + * @example + * writer.on('progress', onProgress) + */ + const onProgress = (state: sdk.multiWrite.MultiDestinationProgress) => { + ipc.of[IPC_SERVER_ID].emit('state', state); + }; + + let exitCode = SUCCESS; + + /** + * @summary Abort handler + * @example + * writer.on('abort', onAbort) + */ + const onAbort = async () => { + log('Abort'); + ipc.of[IPC_SERVER_ID].emit('abort'); + await delay(DISCONNECT_DELAY); + terminate(exitCode); + }; + + ipc.of[IPC_SERVER_ID].on('cancel', onAbort); + + /** + * @summary Failure handler (non-fatal errors) + * @param {SourceDestination} destination - destination + * @param {Error} error - error + * @example + * writer.on('fail', onFail) + */ + const onFail = ( + destination: sdk.sourceDestination.BlockDevice, + error: Error, + ) => { + ipc.of[IPC_SERVER_ID].emit('fail', { + // TODO: device should be destination + // @ts-ignore (destination.drive is private) + device: destination.drive, + error: toJSON(error), + }); + }; + + const destinations = _.map(options.destinations, 'device'); + log(`Image: ${options.imagePath}`); + log(`Devices: ${destinations.join(', ')}`); + log(`Umount on success: ${options.unmountOnSuccess}`); + log(`Validate on success: ${options.validateWriteOnSuccess}`); + log(`Trim: ${options.trim}`); + const dests = _.map(options.destinations, destination => { + return new sdk.sourceDestination.BlockDevice( + destination, + options.unmountOnSuccess, + ); + }); + const source = new sdk.sourceDestination.File( + options.imagePath, + sdk.sourceDestination.File.OpenFlags.Read, + ); + try { + const results = await writeAndValidate( + // @ts-ignore FIXME: ts thinks that SparseWriteStream can't be assigned to SparseWritable (which it implements) + source, + dests, + options.validateWriteOnSuccess, + options.trim, + onProgress, + onFail, + ); + log(`Finish: ${results.bytesWritten}`); + results.errors = _.map(results.errors, error => { + return toJSON(error); + }); + ipc.of[IPC_SERVER_ID].emit('done', { results }); + await delay(DISCONNECT_DELAY); + terminate(exitCode); + } catch (error) { + log(`Error: ${error.message}`); + exitCode = GENERAL_ERROR; + ipc.of[IPC_SERVER_ID].emit('error', toJSON(error)); + } + }); + + ipc.of[IPC_SERVER_ID].on('connect', () => { + log( + `Successfully connected to IPC server: ${IPC_SERVER_ID}, socket root ${ipc.config.socketRoot}`, + ); + ipc.of[IPC_SERVER_ID].emit('ready', {}); + }); +}); diff --git a/lib/start.js b/lib/start.js index 28c90d5d..668b54bc 100644 --- a/lib/start.js +++ b/lib/start.js @@ -24,6 +24,7 @@ // or the entry point file (this file) manually as an argument. if (process.env.ELECTRON_RUN_AS_NODE) { + // eslint-disable-next-line node/no-missing-require require('./gui/modules/child-writer') } else { require('./gui/etcher') diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index f83b8d04..28f042f0 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -1215,6 +1215,15 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-12.12.24.tgz", "integrity": "sha512-1Ciqv9pqwVtW6FsIUKSZNB82E5Cu1I2bBTj1xuIHXLe/1zYLl3956Nbhg2MzSYHVfl9/rmanjbQIb7LibfCnug==" }, + "@types/node-ipc": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/@types/node-ipc/-/node-ipc-9.1.2.tgz", + "integrity": "sha512-140YlGizUg2Dbbmypc97RZ2iaWOEdcwec6QPJ9C5AWy8H/Hus6co4MeEF2lRPmOTBY3GJu+Xaxyr4FfyE6Hjew==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@types/normalize-package-data": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.0.tgz", diff --git a/package.json b/package.json index d2f1471e..60d94ce8 100644 --- a/package.json +++ b/package.json @@ -97,6 +97,7 @@ "@types/bindings": "^1.3.0", "@types/mime-types": "^2.1.0", "@types/node": "^12.12.24", + "@types/node-ipc": "^9.1.2", "@types/react-dom": "^16.8.4", "@types/request": "^2.48.4", "@types/semver": "^6.2.0", diff --git a/tests/gui/modules/child-writer.spec.js b/tests/gui/modules/child-writer.spec.js index 3b5929ec..bccfec97 100644 --- a/tests/gui/modules/child-writer.spec.js +++ b/tests/gui/modules/child-writer.spec.js @@ -18,6 +18,7 @@ const m = require('mochainon') const ipc = require('node-ipc') +// eslint-disable-next-line node/no-missing-require require('../../../lib/gui/modules/child-writer') describe('Browser: childWriter', function () {