diff --git a/src/analyzer.ts b/src/analyzer.ts index ab779d35f..a531fc785 100644 --- a/src/analyzer.ts +++ b/src/analyzer.ts @@ -1082,19 +1082,44 @@ export class Analyzer { }; return {ok: false, error: this._markAsInvalid(config, failure)}; } - { - const validConfig: ScriptConfig = { + + let validConfig: ScriptConfig; + if (config.service) { + // We should already have created an invalid script at this point, so we + // should never get here. We throw here to convince TypeScript that this + // is guaranteed. + if (config.command === undefined) { + throw new Error( + 'Internal error: Supposedly valid service did not have command' + ); + } + validConfig = { ...config, + state: 'valid', extraArgs: undefined, + dependencies: config.dependencies as Array>, + // Unfortunately TypeScript doesn't narrow the ...config spread, so we + // have to assign explicitly. + command: config.command, + }; + } else { + validConfig = { + ...config, state: 'valid', + extraArgs: undefined, dependencies: config.dependencies as Array>, + // Unfortunately TypeScript doesn't narrow the ...config spread, so we + // have to assign explicitly. + service: config.service, }; - // We want to keep the original reference, but get type checking that - // the only difference between a ScriptConfig and a - // LocallyValidScriptConfig is that the state is 'valid' and the - // dependencies are also valid, which we confirmed above. - Object.assign(config, validConfig); } + + // We want to keep the original reference, but get type checking that + // the only difference between a ScriptConfig and a + // LocallyValidScriptConfig is that the state is 'valid' and the + // dependencies are also valid, which we confirmed above. + Object.assign(config, validConfig); + return {ok: true, value: config as unknown as ScriptConfig}; } diff --git a/src/config.ts b/src/config.ts index f2eae08a7..32e6031fa 100644 --- a/src/config.ts +++ b/src/config.ts @@ -29,12 +29,30 @@ export interface ScriptReference extends PackageReference { name: string; } +/** + * A script with a defined command. + */ +export interface ScriptReferenceWithCommand extends ScriptReference { + /** + * The shell command to execute. + */ + command: JsonAstNode; + + /** + * Extra arguments to pass to the command. + */ + extraArgs: string[] | undefined; +} + export interface Dependency { config: Config; astNode: JsonAstNode; } -export type ScriptConfig = NoCommandScriptConfig | StandardScriptConfig; +export type ScriptConfig = + | NoCommandScriptConfig + | StandardScriptConfig + | ServiceScriptConfig; /** * A script that doesn't run or produce anything. A pass-through for @@ -43,21 +61,25 @@ export type ScriptConfig = NoCommandScriptConfig | StandardScriptConfig; export interface NoCommandScriptConfig extends BaseScriptConfig { command: undefined; extraArgs: undefined; + service: false; } /** * A script with a command that exits by itself. */ -export interface StandardScriptConfig extends BaseScriptConfig { - /** - * The shell command to execute. - */ - command: JsonAstNode; +export interface StandardScriptConfig + extends BaseScriptConfig, + ScriptReferenceWithCommand { + service: false; +} - /** - * Extra arguments to pass to the command. - */ - extraArgs: string[] | undefined; +/** + * A service script. + */ +export interface ServiceScriptConfig + extends BaseScriptConfig, + ScriptReferenceWithCommand { + service: true; } /** diff --git a/src/event.ts b/src/event.ts index d030c8f80..60963fbbc 100644 --- a/src/event.ts +++ b/src/event.ts @@ -9,6 +9,7 @@ import {Diagnostic} from './error.js'; import type { ScriptConfig, ScriptReference, + ScriptReferenceWithCommand, PackageReference, } from './config.js'; @@ -20,7 +21,7 @@ import type { */ export type Event = Success | Failure | Output | Info; -interface EventBase { +interface EventBase { script: T; diagnostic?: Diagnostic; diagnostics?: Diagnostic[]; @@ -32,35 +33,36 @@ interface EventBase { type Success = ExitZero | NoCommand | Fresh | Cached; -interface SuccessBase extends EventBase { +interface SuccessBase + extends EventBase { type: 'success'; } /** * A script finished with exit code 0. */ -export interface ExitZero extends SuccessBase { +export interface ExitZero extends SuccessBase { reason: 'exit-zero'; } /** * A script completed because it had no command and its dependencies completed. */ -export interface NoCommand extends SuccessBase { +export interface NoCommand extends SuccessBase { reason: 'no-command'; } /** * A script was already fresh so it didn't need to execute. */ -export interface Fresh extends SuccessBase { +export interface Fresh extends SuccessBase { reason: 'fresh'; } /** * Script output was restored from cache. */ -export interface Cached extends SuccessBase { +export interface Cached extends SuccessBase { reason: 'cached'; } @@ -90,16 +92,18 @@ export type Failure = | UnknownErrorThrown | DependencyOnMissingPackageJson | DependencyOnMissingScript - | DependencyInvalid; + | DependencyInvalid + | ServiceExitedUnexpectedly; -interface ErrorBase extends EventBase { +interface ErrorBase + extends EventBase { type: 'failure'; } /** * A script finished with an exit status that was not 0. */ -export interface ExitNonZero extends ErrorBase { +export interface ExitNonZero extends ErrorBase { reason: 'exit-non-zero'; status: number; } @@ -107,7 +111,7 @@ export interface ExitNonZero extends ErrorBase { /** * A script exited because of a signal it received. */ -export interface ExitSignal extends ErrorBase { +export interface ExitSignal extends ErrorBase { reason: 'signal'; signal: NodeJS.Signals; } @@ -115,7 +119,7 @@ export interface ExitSignal extends ErrorBase { /** * An error occured trying to spawn a script's command. */ -export interface SpawnError extends ErrorBase { +export interface SpawnError extends ErrorBase { reason: 'spawn-error'; message: string; } @@ -124,14 +128,14 @@ export interface SpawnError extends ErrorBase { * We decided not to start a script after all, due to e.g. another script * failure. */ -export interface StartCancelled extends ErrorBase { +export interface StartCancelled extends ErrorBase { reason: 'start-cancelled'; } /** * A script was intentionally and successfully killed by Wireit. */ -export interface Killed extends ErrorBase { +export interface Killed extends ErrorBase { reason: 'killed'; } @@ -162,15 +166,14 @@ export interface PackageJsonParseError extends ErrorBase { /** * The package.json doesn't have a "scripts" object at all. */ -export interface NoScriptsSectionInPackageJson - extends ErrorBase { +export interface NoScriptsSectionInPackageJson extends ErrorBase { reason: 'no-scripts-in-package-json'; } /** * The specified script does not exist in a package.json. */ -export interface ScriptNotFound extends ErrorBase { +export interface ScriptNotFound extends ErrorBase { reason: 'script-not-found'; diagnostic: Diagnostic; } @@ -179,8 +182,7 @@ export interface ScriptNotFound extends ErrorBase { * The specified script has a wireit config, but it isn't declared in the * scripts section at all. */ -export interface WireitScriptNotInScriptsSection - extends ErrorBase { +export interface WireitScriptNotInScriptsSection extends ErrorBase { reason: 'wireit-config-but-no-script'; diagnostic: Diagnostic; } @@ -188,7 +190,7 @@ export interface WireitScriptNotInScriptsSection /** * The specified script's command is not "wireit". */ -export interface ScriptNotWireit extends ErrorBase { +export interface ScriptNotWireit extends ErrorBase { reason: 'script-not-wireit'; diagnostic: Diagnostic; } @@ -201,7 +203,7 @@ export interface InvalidConfigSyntax extends ErrorBase { diagnostic: Diagnostic; } -export interface InvalidUsage extends ErrorBase { +export interface InvalidUsage extends ErrorBase { reason: 'invalid-usage'; message: string; } @@ -209,7 +211,7 @@ export interface InvalidUsage extends ErrorBase { /** * A script lists the same dependency multiple times. */ -export interface DuplicateDependency extends ErrorBase { +export interface DuplicateDependency extends ErrorBase { reason: 'duplicate-dependency'; /** * The dependency that is duplicated. @@ -221,8 +223,7 @@ export interface DuplicateDependency extends ErrorBase { /** * A script depends on another in a package that isn't there. */ -export interface DependencyOnMissingPackageJson - extends ErrorBase { +export interface DependencyOnMissingPackageJson extends ErrorBase { reason: 'dependency-on-missing-package-json'; diagnostic: Diagnostic; /** @@ -235,12 +236,19 @@ export interface DependencyOnMissingPackageJson /** * A script's dependency doesn't exist. */ -export interface DependencyOnMissingScript extends ErrorBase { +export interface DependencyOnMissingScript extends ErrorBase { reason: 'dependency-on-missing-script'; diagnostic: Diagnostic; supercedes: ScriptNotFound; } +/** + * A service exited before it was supposed to. + */ +export interface ServiceExitedUnexpectedly extends ErrorBase { + reason: 'service-exited-unexpectedly'; +} + /** * We reached the point of doing cyclic dependency checking, and one of our * transitive dependencies had not transitioned to being locally validated. @@ -251,7 +259,7 @@ export interface DependencyOnMissingScript extends ErrorBase { * continue to cycle detection even when some diagnostics were generated during * local analysis. */ -export interface DependencyInvalid extends ErrorBase { +export interface DependencyInvalid extends ErrorBase { reason: 'dependency-invalid'; dependency: UnvalidatedConfig; } @@ -259,7 +267,7 @@ export interface DependencyInvalid extends ErrorBase { /** * The dependency graph has a cycle in it. */ -export interface Cycle extends ErrorBase { +export interface Cycle extends ErrorBase { reason: 'cycle'; diagnostic: Diagnostic; @@ -268,7 +276,7 @@ export interface Cycle extends ErrorBase { /** * For when we catch an error not handled by any of the other types. */ -export interface UnknownErrorThrown extends ErrorBase { +export interface UnknownErrorThrown extends ErrorBase { reason: 'unknown-error-thrown'; error: unknown; } @@ -308,16 +316,19 @@ type Info = | OutputModified | WatchRunStart | WatchRunEnd + | ServiceStarted + | ServiceStopped | GenericInfo; -interface InfoBase extends EventBase { +interface InfoBase + extends EventBase { type: 'info'; } /** * A script's command started running. */ -export interface ScriptRunning extends InfoBase { +export interface ScriptRunning extends InfoBase { detail: 'running'; } @@ -325,7 +336,7 @@ export interface ScriptRunning extends InfoBase { * A script can't run right now because a system-wide lock is being held by * another process. */ -export interface ScriptLocked extends InfoBase { +export interface ScriptLocked extends InfoBase { detail: 'locked'; } @@ -334,28 +345,42 @@ export interface ScriptLocked extends InfoBase { * stale, because one or more output files from the previous run have been * added, removed, or changed. */ -export interface OutputModified extends InfoBase { +export interface OutputModified extends InfoBase { detail: 'output-modified'; } /** * A watch mode iteration started. */ -export interface WatchRunStart extends InfoBase { +export interface WatchRunStart extends InfoBase { detail: 'watch-run-start'; } /** * A watch mode iteration ended. */ -export interface WatchRunEnd extends InfoBase { +export interface WatchRunEnd extends InfoBase { detail: 'watch-run-end'; } +/** + * A service started running. + */ +export interface ServiceStarted extends InfoBase { + detail: 'service-started'; +} + +/** + * A service stopped running. + */ +export interface ServiceStopped extends InfoBase { + detail: 'service-stopped'; +} + /** * A generic info event. */ -export interface GenericInfo extends InfoBase { +export interface GenericInfo extends InfoBase { detail: 'generic'; message: string; } diff --git a/src/execution/service.ts b/src/execution/service.ts new file mode 100644 index 000000000..20311f624 --- /dev/null +++ b/src/execution/service.ts @@ -0,0 +1,40 @@ +/** + * @license + * Copyright 2022 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {BaseExecution} from './base.js'; +import {Fingerprint} from '../fingerprint.js'; + +import type {ExecutionResult} from './base.js'; +import type {Executor} from '../executor.js'; +import type {ServiceScriptConfig} from '../config.js'; +import type {Logger} from '../logging/logger.js'; + +/** + * Execution for a {@link ServiceScriptConfig}. + */ +export class ServiceScriptExecution extends BaseExecution { + static execute( + script: ServiceScriptConfig, + executor: Executor, + logger: Logger + ): Promise { + return new ServiceScriptExecution(script, executor, logger)._execute(); + } + + private async _execute(): Promise { + const dependencyFingerprints = await this.executeDependencies(); + if (!dependencyFingerprints.ok) { + return dependencyFingerprints; + } + const fingerprint = await Fingerprint.compute( + this.script, + dependencyFingerprints.value + ); + return {ok: true, value: fingerprint}; + } + + // TODO(aomarks) Implement service starting/stopping. +} diff --git a/src/executor.ts b/src/executor.ts index c5a00fcc3..b4a0cdc63 100644 --- a/src/executor.ts +++ b/src/executor.ts @@ -6,6 +6,7 @@ import {NoCommandScriptExecution} from './execution/no-command.js'; import {StandardScriptExecution} from './execution/standard.js'; +import {ServiceScriptExecution} from './execution/service.js'; import {ScriptConfig, scriptReferenceToString} from './config.js'; import {WorkerPool} from './util/worker-pool.js'; import {Deferred} from './util/deferred.js'; @@ -134,6 +135,9 @@ export class Executor { if (script.command === undefined) { return NoCommandScriptExecution.execute(script, this, this._logger); } + if (script.service) { + return ServiceScriptExecution.execute(script, this, this._logger); + } return StandardScriptExecution.execute( script, this, diff --git a/src/logging/default-logger.ts b/src/logging/default-logger.ts index 852c338a0..ce08a5cfc 100644 --- a/src/logging/default-logger.ts +++ b/src/logging/default-logger.ts @@ -198,6 +198,11 @@ export class DefaultLogger implements Logger { event.dependency )} which could not be validated. Please file a bug at https://github.com/google/wireit/issues/new, mention this message, that you encountered it in wireit version ${getWireitVersion()}, and give information about your package.json files.` ); + break; + } + case 'service-exited-unexpectedly': { + console.error(`❌${prefix} Service exited unexpectedly`); + break; } } break; @@ -275,6 +280,14 @@ export class DefaultLogger implements Logger { console.log(`ℹ️${prefix} ${event.message}`); break; } + case 'service-started': { + console.log(`⬆️${prefix} Service started`); + break; + } + case 'service-stopped': { + console.log(`⬇️${prefix} Service stopped`); + break; + } } } } diff --git a/src/script-child-process.ts b/src/script-child-process.ts index 05df0b8f3..7f2ccc3ef 100644 --- a/src/script-child-process.ts +++ b/src/script-child-process.ts @@ -10,9 +10,10 @@ import { augmentProcessEnvSafelyIfOnWindows, IS_WINDOWS, } from './util/windows.js'; +import {Deferred} from './util/deferred.js'; import type {Result} from './error.js'; -import type {ScriptConfig} from './config.js'; +import type {ScriptReferenceWithCommand} from './config.js'; import type {ChildProcessWithoutNullStreams} from 'child_process'; import type {ExitNonZero, ExitSignal, SpawnError, Killed} from './event.js'; @@ -44,27 +45,27 @@ export type ScriptChildProcessState = | 'killing' | 'stopped'; -/** - * A script config but the command is required. - */ -type ScriptConfigWithRequiredCommand = ScriptConfig & { - command: Exclude; -}; - /** * A child process spawned during execution of a script. */ export class ScriptChildProcess { - private readonly _script: ScriptConfigWithRequiredCommand; + private readonly _script: ScriptReferenceWithCommand; private readonly _child: ChildProcessWithoutNullStreams; + private readonly _started = new Deferred>(); + private readonly _completed = new Deferred< + Result + >(); private _state: ScriptChildProcessState = 'starting'; + /** + * Resolves when this process starts + */ + readonly started = this._started.promise; + /** * Resolves when this child process ends. */ - readonly completed: Promise< - Result - >; + readonly completed = this._completed.promise; get stdout() { return this._child.stdout; @@ -74,13 +75,21 @@ export class ScriptChildProcess { return this._child.stderr; } - constructor(script: ScriptConfigWithRequiredCommand) { - this._script = script; + constructor(script: ScriptReferenceWithCommand) { + // Copy only the fields we actually require from the script config, because + // the full script config contains references to the full config, which we + // want to allow to be garbage-collected across watch iterations. + this._script = { + packageDir: script.packageDir, + name: script.name, + command: script.command, + extraArgs: script.extraArgs, + }; // TODO(aomarks) Update npm_ environment variables to reflect the new // package. - this._child = spawn(script.command.value, script.extraArgs, { - cwd: script.packageDir, + this._child = spawn(this._script.command.value, this._script.extraArgs, { + cwd: this._script.packageDir, // Conveniently, "shell:true" has the same shell-selection behavior as // "npm run", where on macOS and Linux it is "sh", and on Windows it is // %COMSPEC% || "cmd.exe". @@ -113,94 +122,96 @@ export class ScriptChildProcess { detached: !IS_WINDOWS, }); - this.completed = new Promise((resolve, reject) => { - this._child.on('spawn', () => { - switch (this._state) { - case 'starting': { - this._state = 'started'; - break; - } - case 'killing': { - // We received a kill request while we were still starting. Kill now - // that we're started. - this._actuallyKill(); - break; - } - case 'started': - case 'stopped': { - reject( - new Error( - `Internal error: Expected ScriptChildProcessState ` + - `to be "started" or "killing" but was "${this._state}"` - ) - ); - break; - } - default: { - const never: never = this._state; - reject( - new Error( - `Internal error: unexpected ScriptChildProcessState: ${String( - never - )}` - ) - ); - } + this._child.on('spawn', () => { + switch (this._state) { + case 'starting': { + this._started.resolve({ok: true, value: undefined}); + this._state = 'started'; + break; } - }); + case 'killing': { + this._started.resolve({ok: true, value: undefined}); + // We received a kill request while we were still starting. Kill now + // that we're started. + this._actuallyKill(); + break; + } + case 'started': + case 'stopped': { + const exception = new Error( + `Internal error: Expected ScriptChildProcessState ` + + `to be "started" or "killing" but was "${this._state}"` + ); + this._started.reject(exception); + this._completed.reject(exception); + break; + } + default: { + const never: never = this._state; + const exception = new Error( + `Internal error: unexpected ScriptChildProcessState: ${String( + never + )}` + ); + this._started.reject(exception); + this._completed.reject(exception); + } + } + }); + + this._child.on('error', (error) => { + const result = { + ok: false, + error: { + script, + type: 'failure', + reason: 'spawn-error', + message: error.message, + }, + } as const; + this._started.resolve(result); + this._completed.resolve(result); + this._state = 'stopped'; + }); - this._child.on('error', (error) => { - resolve({ + this._child.on('close', (status, signal) => { + if (this._state === 'killing') { + this._completed.resolve({ ok: false, error: { script, type: 'failure', - reason: 'spawn-error', - message: error.message, + reason: 'killed', }, }); - this._state = 'stopped'; - }); - - this._child.on('close', (status, signal) => { - if (this._state === 'killing') { - resolve({ - ok: false, - error: { - script, - type: 'failure', - reason: 'killed', - }, - }); - } else if (signal !== null) { - resolve({ - ok: false, - error: { - script, - type: 'failure', - reason: 'signal', - signal, - }, - }); - } else if (status !== 0) { - resolve({ - ok: false, - error: { - script, - type: 'failure', - reason: 'exit-non-zero', - // status should only ever be null if signal was not null, but - // this isn't reflected in the TypeScript types. Just in case, and - // to make TypeScript happy, fall back to -1 (which is a - // conventional exit status used for "exited with signal"). - status: status ?? -1, - }, - }); - } else { - resolve({ok: true, value: undefined}); - } - this._state = 'stopped'; - }); + } else if (signal !== null) { + this._completed.resolve({ + ok: false, + error: { + script, + type: 'failure', + reason: 'signal', + signal, + }, + }); + } else if (status !== 0) { + this._completed.resolve({ + ok: false, + error: { + script, + type: 'failure', + reason: 'exit-non-zero', + // status should only ever be null if signal was not null, but + // this isn't reflected in the TypeScript types. Just in case, and + // to make TypeScript happy, fall back to -1 (which is a + // conventional exit status used for "exited with signal"). + status: status ?? -1, + }, + }); + } else { + this._completed.resolve({ok: true, value: undefined}); + } + this._state = 'stopped'; }); }