diff --git a/src/execution/service.ts b/src/execution/service.ts index 2fdf3bc83..1cf5924de 100644 --- a/src/execution/service.ts +++ b/src/execution/service.ts @@ -7,14 +7,48 @@ import {BaseExecutionWithCommand} from './base.js'; import {Fingerprint} from '../fingerprint.js'; import {Deferred} from '../util/deferred.js'; +import {ScriptChildProcess} from '../script-child-process.js'; import type {ExecutionResult} from './base.js'; -import type {ServiceScriptConfig} from '../config.js'; +import type {ScriptReference, ServiceScriptConfig} from '../config.js'; import type {Executor} from '../executor.js'; import type {Logger} from '../logging/logger.js'; import type {Failure} from '../event.js'; import type {Result} from '../error.js'; +type ServiceState = + | {id: 'initial'} + | { + id: 'executingDeps'; + fingerprint: Deferred; + } + | { + id: 'fingerprinting'; + fingerprint: Deferred; + } + | {id: 'unstarted'} + | { + id: 'starting'; + child: ScriptChildProcess; + started: Deferred>; + } + | { + id: 'started'; + child: ScriptChildProcess; + } + | {id: 'stopping'} + | {id: 'stopped'}; + +function unknownState(state: never) { + return new Error( + `Unknown service state ${String((state as ServiceState).id)}` + ); +} + +function unexpectedState(state: ServiceState) { + return new Error(`Unexpected service state ${state.id}`); +} + /** * Execution for a {@link ServiceScriptConfig}. * @@ -33,7 +67,13 @@ import type {Result} from '../error.js'; * ▼ execute * │ │ * │ ┌───────▼────────┐ - * ├─◄─ abort ─┤ FINGERPRINTING ├──── depExecErr ────►───╮ + * ├─◄─ abort ─┤ EXECUTING_DEPS ├──── depExecErr ────►───╮ + * │ └───────┬────────┘ │ + * │ │ │ + * ▼ depsExecuted │ + * │ │ │ + * │ ┌───────▼────────┐ │ + * ├─◄─ abort ─┤ FINGERPRINTING │ │ * │ └───────┬────────┘ │ * │ │ │ * ▼ fingerprinted │ @@ -82,6 +122,7 @@ import type {Result} from '../error.js'; * ``` */ export class ServiceScriptExecution extends BaseExecutionWithCommand { + private _state: ServiceState = {id: 'initial'}; private readonly _terminated = new Deferred>(); /** @@ -108,23 +149,229 @@ export class ServiceScriptExecution extends BaseExecutionWithCommand { - const dependencyFingerprints = await this._executeDependencies(); - if (!dependencyFingerprints.ok) { - return dependencyFingerprints; + protected override _execute(): Promise { + switch (this._state.id) { + case 'initial': { + this._state = { + id: 'executingDeps', + fingerprint: new Deferred(), + }; + void this._executeDependencies().then((result) => { + if (result.ok) { + this._onDepsExecuted(result.value); + } else { + this._onDepExecErr(result); + } + }); + return this._state.fingerprint.promise; + } + case 'executingDeps': + case 'fingerprinting': + case 'unstarted': + case 'starting': + case 'started': + case 'stopping': + case 'stopped': { + throw unexpectedState(this._state); + } + default: { + throw unknownState(this._state); + } + } + } + + private _onDepsExecuted( + depFingerprints: Array<[ScriptReference, Fingerprint]> + ): void { + switch (this._state.id) { + case 'executingDeps': { + this._state = { + id: 'fingerprinting', + fingerprint: this._state.fingerprint, + }; + void Fingerprint.compute(this._config, depFingerprints).then( + (result) => { + this._onFingerprinted(result); + } + ); + return; + } + case 'initial': + case 'fingerprinting': + case 'unstarted': + case 'starting': + case 'started': + case 'stopping': + case 'stopped': { + throw unexpectedState(this._state); + } + default: { + throw unknownState(this._state); + } + } + } + + private _onDepExecErr(result: ExecutionResult & {ok: false}) { + switch (this._state.id) { + case 'executingDeps': { + this._state.fingerprint.resolve(result); + return; + } + case 'initial': + case 'fingerprinting': + case 'unstarted': + case 'starting': + case 'started': + case 'stopping': + case 'stopped': { + throw unexpectedState(this._state); + } + default: { + throw unknownState(this._state); + } + } + } + + private _onFingerprinted(fingerprint: Fingerprint) { + switch (this._state.id) { + case 'fingerprinting': { + this._state.fingerprint.resolve({ok: true, value: fingerprint}); + this._state = {id: 'unstarted'}; + return; + } + case 'initial': + case 'executingDeps': + case 'unstarted': + case 'starting': + case 'started': + case 'stopping': + case 'stopped': { + throw unexpectedState(this._state); + } + default: { + throw unknownState(this._state); + } } - const fingerprint = await Fingerprint.compute( - this._config, - dependencyFingerprints.value - ); - return {ok: true, value: fingerprint}; } /** * Start this service if it isn't already started. */ start(): Promise> { - // TODO(aomarks) Implement service starting/stopping. - throw new Error('Not implemented'); + switch (this._state.id) { + case 'unstarted': { + this._state = { + id: 'starting', + child: new ScriptChildProcess(this._config), + started: new Deferred(), + }; + void this._state.child.started.then(() => { + this._onChildStarted(); + }); + void this._state.child.completed.then(() => { + this._onChildExited(); + }); + return this._state.started.promise; + } + case 'initial': + case 'executingDeps': + case 'fingerprinting': + case 'starting': + case 'started': + case 'stopping': + case 'stopped': { + throw unexpectedState(this._state); + } + default: { + throw unknownState(this._state); + } + } + } + + private _onChildStarted() { + switch (this._state.id) { + case 'starting': { + this._state.started.resolve({ok: true, value: undefined}); + this._logger.log({ + script: this._config, + type: 'info', + detail: 'service-started', + }); + this._state = { + id: 'started', + child: this._state.child, + }; + const allConsumersDone = Promise.all( + this._config.serviceConsumers.map( + (consumer) => + this._executor.getExecution(consumer).servicesNotNeeded + ) + ); + void allConsumersDone.then(() => { + this._allConsumersDone(); + }); + return; + } + case 'initial': + case 'executingDeps': + case 'fingerprinting': + case 'unstarted': + case 'started': + case 'stopping': + case 'stopped': { + throw unexpectedState(this._state); + } + default: { + throw unknownState(this._state); + } + } + } + + private _allConsumersDone() { + switch (this._state.id) { + case 'started': { + this._state.child.kill(); + this._state = {id: 'stopping'}; + return; + } + case 'initial': + case 'executingDeps': + case 'fingerprinting': + case 'unstarted': + case 'starting': + case 'stopping': + case 'stopped': { + throw unexpectedState(this._state); + } + default: { + throw unknownState(this._state); + } + } + } + + private _onChildExited() { + switch (this._state.id) { + case 'stopping': { + this._state = {id: 'stopped'}; + this._logger.log({ + script: this._config, + type: 'info', + detail: 'service-stopped', + }); + return; + } + case 'initial': + case 'executingDeps': + case 'fingerprinting': + case 'unstarted': + case 'starting': + case 'started': + case 'stopped': { + throw unexpectedState(this._state); + } + default: { + throw unknownState(this._state); + } + } } } diff --git a/src/execution/standard.ts b/src/execution/standard.ts index 04904d1af..f73e6939c 100644 --- a/src/execution/standard.ts +++ b/src/execution/standard.ts @@ -75,7 +75,7 @@ export class StandardScriptExecution extends BaseExecutionWithCommand { + return await this._acquireSystemLockIfNeeded(async () => { // Note we must wait for dependencies to finish before generating the // cache key, because a dependency could create or modify an input file to // this script, which would affect the key. diff --git a/src/test/service.test.ts b/src/test/service.test.ts index fb42dd613..1ed8eced2 100644 --- a/src/test/service.test.ts +++ b/src/test/service.test.ts @@ -5,6 +5,8 @@ */ import {suite} from 'uvu'; +import * as assert from 'uvu/assert'; +import {timeout} from './util/uvu-timeout.js'; import {WireitTestRig} from './util/test-rig.js'; const test = suite<{rig: WireitTestRig}>(); @@ -32,4 +34,56 @@ test.after.each(async (ctx) => { } }); +test( + 'simple consumer and service', + timeout(async ({rig}) => { + // consumer + // | + // v + // service + + const consumer = await rig.newCommand(); + const service = await rig.newCommand(); + await rig.writeAtomic({ + 'package.json': { + scripts: { + consumer: 'wireit', + service: 'wireit', + }, + wireit: { + consumer: { + command: consumer.command, + dependencies: ['service'], + }, + service: { + command: service.command, + service: true, + }, + }, + }, + }); + + const wireit = rig.exec('npm run consumer'); + + // The service starts because the consumer depends on it + const serviceInv = await service.nextInvocation(); + await wireit.waitForLog(/Service started/); + + // The consumer starts and finishes + const consumerInv = await consumer.nextInvocation(); + // Wait a moment to ensure the service stays running + await new Promise((resolve) => setTimeout(resolve, 100)); + assert.ok(serviceInv.isRunning); + consumerInv.exit(0); + + // The service stops because the consumer is done + await serviceInv.closed; + await wireit.waitForLog(/Service stopped/); + + await wireit.exit; + assert.equal(service.numInvocations, 1); + assert.equal(consumer.numInvocations, 1); + }) +); + test.run(); diff --git a/src/test/util/test-rig-command.ts b/src/test/util/test-rig-command.ts index 30d6ae1b2..764c8bbd4 100644 --- a/src/test/util/test-rig-command.ts +++ b/src/test/util/test-rig-command.ts @@ -194,6 +194,13 @@ export class WireitTestRigCommandInvocation extends IpcClient< return this._closed.promise; } + /** + * Return whether this invocation is still running. + */ + get isRunning(): boolean { + return this._state === 'connected'; + } + /** * Tell this invocation to exit with the given code. */