Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
273 changes: 260 additions & 13 deletions src/execution/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ExecutionResult>;
}
| {
id: 'fingerprinting';
fingerprint: Deferred<ExecutionResult>;
}
| {id: 'unstarted'}
| {
id: 'starting';
child: ScriptChildProcess;
started: Deferred<Result<void, Failure[]>>;
}
| {
id: 'started';
child: ScriptChildProcess;
}
| {id: 'stopping'}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this state still have an associated child process?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think it's needed, but if it turns out to be then I will add it. I'm only adding state data as needed.

| {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}.
*
Expand All @@ -33,7 +67,13 @@ import type {Result} from '../error.js';
* ▼ execute
* │ │
* │ ┌───────▼────────┐
* ├─◄─ abort ─┤ FINGERPRINTING ├──── depExecErr ────►───╮
* ├─◄─ abort ─┤ EXECUTING_DEPS ├──── depExecErr ────►───╮
* │ └───────┬────────┘ │
* │ │ │
* ▼ depsExecuted │
* │ │ │
* │ ┌───────▼────────┐ │
* ├─◄─ abort ─┤ FINGERPRINTING │ │
* │ └───────┬────────┘ │
* │ │ │
* ▼ fingerprinted │
Expand Down Expand Up @@ -82,6 +122,7 @@ import type {Result} from '../error.js';
* ```
*/
export class ServiceScriptExecution extends BaseExecutionWithCommand<ServiceScriptConfig> {
private _state: ServiceState = {id: 'initial'};
private readonly _terminated = new Deferred<Result<void, Failure>>();

/**
Expand All @@ -108,23 +149,229 @@ export class ServiceScriptExecution extends BaseExecutionWithCommand<ServiceScri
* Note `execute` is a bit of a misnomer here, because we don't actually
* execute the command at this stage in the case of services.
*/
protected override async _execute(): Promise<ExecutionResult> {
const dependencyFingerprints = await this._executeDependencies();
if (!dependencyFingerprints.ok) {
return dependencyFingerprints;
protected override _execute(): Promise<ExecutionResult> {
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<Result<void, Failure[]>> {
// 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 = {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should the state transition after the promises below settle?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No. The new state is started, and we set up a handler for the "all consumers done" event, which allows the service to shut down.

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);
}
}
}
}
2 changes: 1 addition & 1 deletion src/execution/standard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ export class StandardScriptExecution extends BaseExecutionWithCommand<StandardSc
return {ok: false, error: [this._startCancelledEvent]};
}

return this._acquireSystemLockIfNeeded(async () => {
return await this._acquireSystemLockIfNeeded(async () => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a bit subtle!

// 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.
Expand Down
54 changes: 54 additions & 0 deletions src/test/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}>();
Expand Down Expand Up @@ -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();
Loading