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
12 changes: 5 additions & 7 deletions src/analyzer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -946,7 +946,7 @@ export class Analyzer {
private _checkForCyclesAndSortDependencies(
config: LocallyValidScriptConfig | ScriptConfig | InvalidScriptConfig,
trail: Set<ScriptReferenceString>,
isDirectlyInvoked: boolean
isPersistent: boolean
): Result<ScriptConfig, InvalidScriptConfig> {
if (config.state === 'valid') {
// Already validated.
Expand Down Expand Up @@ -1068,11 +1068,9 @@ export class Analyzer {
this._checkForCyclesAndSortDependencies(
dependency.config,
trail,
// Walk through no-command scripts when determining if something is
// being directly invoked (e.g. if the top-level script has no command
// and simply delegates to one or more other scripts, then those
// dependencies are effectively being directly invoked).
isDirectlyInvoked && config.command === undefined
// Walk through no-command scripts and services when determining if
// something is persistent.
isPersistent && (config.command === undefined || config.service)
);
if (!validDependencyConfigResult.ok) {
return {
Expand Down Expand Up @@ -1128,7 +1126,7 @@ export class Analyzer {
// Unfortunately TypeScript doesn't narrow the ...config spread, so we
// have to assign explicitly.
command: config.command,
isDirectlyInvoked,
isPersistent,
serviceConsumers: [],
};
} else {
Expand Down
31 changes: 29 additions & 2 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,9 +82,36 @@ export interface ServiceScriptConfig
service: true;

/**
* Whether this service is being invoked directly (e.g. `npm run serve`).
* Whether this service persists beyond the initial execution phase.
*
* When true, this service will keep running until the user exits wireit, or
* until its fingerprint changes in watch mode, requiring a restart.
*
* When false, this service will start only if it is needed by a standard
* script, and will stop when that dependent is done. We call these scripts
* "ephemeral".
*
* So, this is true when there is a path from the entrypoint script to the
* service, which does not pass through a standard script.
*
* Example:
*
* start
* (no-command)
* / \
* ▼ ▼
* serve:api serve:static
* (persistent service) (persistent service)
* | |
* ▼ ▼
* serve:db build:assets
* (persistent service) (standard)
* |
* ▼
* serve:playwright
* (ephemeral service)
*/
isDirectlyInvoked: boolean;
isPersistent: boolean;

/**
* Scripts that depend on this service.
Expand Down
20 changes: 12 additions & 8 deletions src/execution/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,9 +125,9 @@ function unexpectedState(state: ServiceState) {
* │ │ │ │
* │ ├─────◄──────────────╯ │
* │ │ │
* ▼ ╔══════════▼═══════════╗
* │ ║ is directly invoked? ╟── yes ──╮ │
* │ ╚══════════╤═══════════╝ │ │
* ▼ ╔═══════▼════════
* │ ║ is persistent? ╟───── yes ──╮ │
* │ ╚═══════╤════════ │ │
* │ │ │ │
* │ no │ │
* │ │ │ │
Expand Down Expand Up @@ -298,7 +298,7 @@ export class ServiceScriptExecution extends BaseExecutionWithCommand<ServiceScri
this._executor.getExecution(consumer).servicesNotNeeded
)
);
const abort = this._config.isDirectlyInvoked
const abort = this._config.isPersistent
? Promise.all([this._state.entireExecutionAborted, allConsumersDone])
: allConsumersDone;
void abort.then(() => {
Expand Down Expand Up @@ -439,7 +439,7 @@ export class ServiceScriptExecution extends BaseExecutionWithCommand<ServiceScri
fingerprint,
adoptee,
};
if (this._config.isDirectlyInvoked) {
if (this._config.isPersistent) {
void this.start();
}
return;
Expand Down Expand Up @@ -478,7 +478,7 @@ export class ServiceScriptExecution extends BaseExecutionWithCommand<ServiceScri
fingerprint: this._state.fingerprint,
adoptee: undefined,
};
if (this._config.isDirectlyInvoked) {
if (this._config.isPersistent) {
void this.start();
}
return;
Expand Down Expand Up @@ -525,6 +525,12 @@ export class ServiceScriptExecution extends BaseExecutionWithCommand<ServiceScri
});
return this._state.started.promise;
}
case 'starting': {
return this._state.started.promise;
}
case 'started': {
return Promise.resolve({ok: true, value: undefined});
}
case 'failing':
case 'failed': {
return Promise.resolve({ok: false, error: [this._state.failure]});
Expand All @@ -534,8 +540,6 @@ export class ServiceScriptExecution extends BaseExecutionWithCommand<ServiceScri
case 'fingerprinting':
case 'stoppingAdoptee':
case 'depsStarting':
case 'starting':
case 'started':
case 'stopping':
case 'stopped':
case 'detached': {
Expand Down
28 changes: 14 additions & 14 deletions src/executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,8 @@ export type FailureMode = 'no-new' | 'continue' | 'kill';
export class Executor {
private readonly _rootConfig: ScriptConfig;
private readonly _executions = new Map<ScriptReferenceString, Execution>();
private readonly _directlyInvokedServices: ServiceMap = new Map();
private readonly _indirectlyInvokedServices: ServiceScriptExecution[] = [];
private readonly _persistentServices: ServiceMap = new Map();
private readonly _ephemeralServices: ServiceScriptExecution[] = [];
private readonly _previousIterationServices: ServiceMap | undefined;
private readonly _logger: Logger;
private readonly _workerPool: WorkerPool;
Expand Down Expand Up @@ -130,16 +130,16 @@ export class Executor {
this._previousIterationServices.size > 0
) {
// If any services were removed from the graph entirely, or used to be
// directly invoked but are no longer, then stop them now.
const currentDirectlyInvokedServices = new Set<ScriptReferenceString>();
// persistent but are no longer, then stop them now.
const currentPersistentServices = new Set<ScriptReferenceString>();
for (const script of findAllScripts(this._rootConfig)) {
if (script.service && script.isDirectlyInvoked) {
currentDirectlyInvokedServices.add(scriptReferenceToString(script));
if (script.service && script.isPersistent) {
currentPersistentServices.add(scriptReferenceToString(script));
}
}
const stopPromises = [];
for (const [key, service] of this._previousIterationServices) {
if (!currentDirectlyInvokedServices.has(key)) {
if (!currentPersistentServices.has(key)) {
const child = service.detach();
if (child !== undefined) {
child.kill();
Expand All @@ -158,18 +158,18 @@ export class Executor {
if (!rootExecutionResult.ok) {
errors.push(...rootExecutionResult.error);
}
const indirectlyInvokedServiceResults = await Promise.all(
this._indirectlyInvokedServices.map((service) => service.terminated)
const ephemeralServiceResults = await Promise.all(
this._ephemeralServices.map((service) => service.terminated)
);
for (const result of indirectlyInvokedServiceResults) {
for (const result of ephemeralServiceResults) {
if (!result.ok) {
errors.push(result.error);
}
}
if (errors.length > 0) {
return {ok: false, error: errors};
}
return {ok: true, value: this._directlyInvokedServices};
return {ok: true, value: this._persistentServices};
}

/**
Expand Down Expand Up @@ -215,10 +215,10 @@ export class Executor {
this._stopServices.promise,
this._previousIterationServices?.get(key)
);
if (config.isDirectlyInvoked) {
this._directlyInvokedServices.set(key, execution);
if (config.isPersistent) {
this._persistentServices.set(key, execution);
} else {
this._indirectlyInvokedServices.push(execution);
this._ephemeralServices.push(execution);
}
} else {
execution = new StandardScriptExecution(
Expand Down
6 changes: 3 additions & 3 deletions src/test/analysis.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,15 +99,15 @@ test('analyzes services', async ({rig}) => {
}
assert.equal(b.serviceConsumers.length, 1);
assert.equal(b.serviceConsumers[0].name, 'd');
assert.equal(b.isDirectlyInvoked, true);
assert.equal(b.isPersistent, true);

// c
const c = a.dependencies[1].config;
assert.equal(c.name, 'c');
if (!c.service) {
throw new Error('Expected service');
}
assert.equal(c.isDirectlyInvoked, true);
assert.equal(c.isPersistent, true);
assert.equal(c.serviceConsumers.length, 0);
assert.equal(c.services.length, 0);

Expand All @@ -124,7 +124,7 @@ test('analyzes services', async ({rig}) => {
if (!e.service) {
throw new Error('Expected service');
}
assert.equal(e.isDirectlyInvoked, false);
assert.equal(e.isPersistent, false);
assert.equal(e.serviceConsumers.length, 1);
});

Expand Down
42 changes: 28 additions & 14 deletions src/test/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -378,7 +378,7 @@ test(
);

test(
'directly invoked service and dependency starts and runs until SIGINT',
'persistent service and dependency starts and runs until SIGINT',
// service1
// |
// v
Expand Down Expand Up @@ -449,11 +449,11 @@ test(
);

for (const failureMode of ['continue', 'no-new', 'kill']) {
// Even directly invoked services which don't have an error in their branch
// should stop when an error occurs elsewhere, regardless of the error mode.
// Even persistent services which don't have an error in their branch should
// stop when an error occurs elsewhere, regardless of the error mode.
// Otherwise wireit won't always exit on failures.
test(
`directly invoked service and dependency stop on error ` +
`persistent service and dependency stop on error ` +
`with failure mode ${failureMode}`,
// entrypoint
// / \
Expand Down Expand Up @@ -546,7 +546,7 @@ for (const failureMode of ['continue', 'no-new', 'kill']) {
}

test(
'indirectly invoked service shuts down between watch iterations',
'ephemeral service shuts down between watch iterations',
timeout(async ({rig}) => {
// consumer
// |
Expand Down Expand Up @@ -606,28 +606,38 @@ test(
);

test(
'directly invoked service is preserved across watch iterations',
'persistent services are preserved across watch iterations',
timeout(async ({rig}) => {
// entrypoint
// / \
// v v
// service standard
// service1 standard
// |
// v
// service2

const service = await rig.newCommand();
const service1 = await rig.newCommand();
const service2 = await rig.newCommand();
const standard = await rig.newCommand();
await rig.writeAtomic({
'package.json': {
scripts: {
entrypoint: 'wireit',
service: 'wireit',
service1: 'wireit',
service2: 'wireit',
standard: 'wireit',
},
wireit: {
entrypoint: {
dependencies: ['service', 'standard'],
dependencies: ['service1', 'standard'],
},
service: {
command: service.command,
service1: {
command: service1.command,
dependencies: ['service2'],
service: true,
},
service2: {
command: service2.command,
service: true,
},
standard: {
Expand All @@ -643,10 +653,12 @@ test(

// Iteration 1
{
await service.nextInvocation();
await service2.nextInvocation();
await service1.nextInvocation();
const standardInv = await standard.nextInvocation();
standardInv.exit(0);
await standardInv.closed;
await wireit.waitForLog(/Watching for file changes/);
}

await rig.write('input', '1');
Expand All @@ -656,11 +668,13 @@ test(
const standardInv = await standard.nextInvocation();
standardInv.exit(0);
await standardInv.closed;
await wireit.waitForLog(/Watching for file changes/);
}

wireit.kill();
await wireit.exit;
assert.equal(service.numInvocations, 1);
assert.equal(service1.numInvocations, 1);
assert.equal(service2.numInvocations, 1);
assert.equal(standard.numInvocations, 2);
})
);
Expand Down