Skip to content

Commit

Permalink
Abort plugin (#848)
Browse files Browse the repository at this point in the history
* Create the `abort` plugin to support killing any active / future tasks for a `simple-git` instance
  • Loading branch information
steveukx authored Aug 28, 2022
1 parent 1cd0dac commit 19029fc
Show file tree
Hide file tree
Showing 20 changed files with 318 additions and 14 deletions.
5 changes: 5 additions & 0 deletions .changeset/gold-mugs-explain.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'simple-git': minor
---

Create the abort plugin to allow cancelling all pending and future tasks.
44 changes: 44 additions & 0 deletions docs/PLUGIN-ABORT-CONTROLLER.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
## Using an AbortController to terminate tasks

The easiest way to send a `SIGKILL` to the `git` child processes created by `simple-git` is to use an `AbortController`
in the constructor options for `simpleGit`:

```typescript
import { simpleGit, GitPluginError, SimpleGit } from 'simple-git';

const controller = new AbortController();

const git: SimpleGit = simpleGit({
baseDir: '/some/path',
abort: controller.signal,
});

try {
await git.pull();
}
catch (err) {
if (err instanceof GitPluginError && err.plugin === 'abort') {
// task failed because `controller.abort` was called while waiting for the `git.pull`
}
}
```

### Examples:

#### Share AbortController across many instances

Run the same operation against multiple repositories, cancel any pending operations when the first has been completed.

```typescript
const repos = [
'/path/to/repo-a',
'/path/to/repo-b',
'/path/to/repo-c',
];

const controller = new AbortController();
const result = await Promise.race(
repos.map(baseDir => simpleGit({ baseDir, abort: controller.signal }).fetch())
);
controller.abort();
```
1 change: 1 addition & 0 deletions packages/test-utils/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './src/create-abort-controller';
export * from './src/create-test-context';
export * from './src/expectations';
export * from './src/instance';
Expand Down
45 changes: 45 additions & 0 deletions packages/test-utils/src/create-abort-controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { setMaxListeners } from 'events';

export function createAbortController() {
if (typeof AbortController === 'undefined') {
return createMockAbortController() as { controller: AbortController; abort: AbortSignal };
}

const controller = new AbortController();
setMaxListeners(1000, controller.signal);
return {
controller,
abort: controller.signal,
mocked: false,
};
}

function createMockAbortController(): unknown {
let aborted = false;
const handlers: Set<() => void> = new Set();
const abort = {
addEventListener(type: 'abort', handler: () => void) {
if (type !== 'abort') throw new Error('Unsupported event name');
handlers.add(handler);
},
removeEventListener(type: 'abort', handler: () => void) {
if (type !== 'abort') throw new Error('Unsupported event name');
handlers.delete(handler);
},
get aborted() {
return aborted;
},
};

return {
controller: {
abort() {
if (aborted) throw new Error('abort called when already aborted');
aborted = true;
handlers.forEach((h) => h());
},
},
abort,
mocked: true,
};
}
2 changes: 1 addition & 1 deletion simple-git/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
"@simple-git/test-utils": "^1.0.0",
"@types/debug": "^4.1.5",
"@types/jest": "^27.0.3",
"@types/node": "^14.14.10",
"@types/node": "^16",
"esbuild": "^0.14.10",
"esbuild-node-externals": "^1.4.1",
"jest": "^27.4.5",
Expand Down
13 changes: 8 additions & 5 deletions simple-git/readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,19 +93,22 @@ await git.pull();

## Configuring Plugins

- [Completion Detection](https://github.com/steveukx/git-js/blob/main/docs/PLUGIN-COMPLETION-DETECTION.md)
- [AbortController](https://github.com/steveukx/git-js/blob/main/docs/PLUGIN-ABORT-CONTROLLER.md)
Terminate pending and future tasks in a `simple-git` instance (requires node >= 16).

- [Completion Detection](https://github.com/steveukx/git-js/blob/main/docs/PLUGIN-COMPLETION-DETECTION.md)
Customise how `simple-git` detects the end of a `git` process.

- [Error Detection](https://github.com/steveukx/git-js/blob/main/docs/PLUGIN-ERRORS.md)
- [Error Detection](https://github.com/steveukx/git-js/blob/main/docs/PLUGIN-ERRORS.md)
Customise the detection of errors from the underlying `git` process.

- [Progress Events](https://github.com/steveukx/git-js/blob/main/docs/PLUGIN-PROGRESS-EVENTS.md)
- [Progress Events](https://github.com/steveukx/git-js/blob/main/docs/PLUGIN-PROGRESS-EVENTS.md)
Receive progress events as `git` works through long-running processes.

- [Spawned Process Ownership](https://github.com/steveukx/git-js/blob/main/docs/PLUGIN-SPAWN-OPTIONS.md)
- [Spawned Process Ownership](https://github.com/steveukx/git-js/blob/main/docs/PLUGIN-SPAWN-OPTIONS.md)
Configure the system `uid` / `gid` to use for spawned `git` processes.

- [Timeout](https://github.com/steveukx/git-js/blob/main/docs/PLUGIN-TIMEOUT.md)
- [Timeout](https://github.com/steveukx/git-js/blob/main/docs/PLUGIN-TIMEOUT.md)
Automatically kill the wrapped `git` process after a rolling timeout.

## Using Task Promises
Expand Down
2 changes: 2 additions & 0 deletions simple-git/src/lib/git-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { SimpleGitFactory } from '../../typings';

import * as api from './api';
import {
abortPlugin,
commandConfigPrefixingPlugin,
completionDetectionPlugin,
errorDetectionHandler,
Expand Down Expand Up @@ -55,6 +56,7 @@ export function gitInstanceFactory(
}

plugins.add(completionDetectionPlugin(config.completion));
config.abort && plugins.add(abortPlugin(config.abort));
config.progress && plugins.add(progressMonitorPlugin(config.progress));
config.timeout && plugins.add(timeoutPlugin(config.timeout));
config.spawnOptions && plugins.add(spawnOptionsPlugin(config.spawnOptions));
Expand Down
33 changes: 33 additions & 0 deletions simple-git/src/lib/plugins/abort-plugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { SimpleGitOptions } from '../types';
import { SimpleGitPlugin } from './simple-git-plugin';
import { GitPluginError } from '../errors/git-plugin-error';

export function abortPlugin(signal: SimpleGitOptions['abort']) {
if (!signal) {
return;
}

const onSpawnAfter: SimpleGitPlugin<'spawn.after'> = {
type: 'spawn.after',
action(_data, context) {
function kill() {
context.kill(new GitPluginError(undefined, 'abort', 'Abort signal received'));
}

signal.addEventListener('abort', kill);

context.spawned.on('close', () => signal.removeEventListener('abort', kill));
},
};

const onSpawnBefore: SimpleGitPlugin<'spawn.before'> = {
type: 'spawn.before',
action(_data, context) {
if (signal.aborted) {
context.kill(new GitPluginError(undefined, 'abort', 'Abort already signaled'));
}
},
};

return [onSpawnBefore, onSpawnAfter];
}
1 change: 1 addition & 0 deletions simple-git/src/lib/plugins/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './abort-plugin';
export * from './command-config-prefixing-plugin';
export * from './completion-detection.plugin';
export * from './error-detection.plugin';
Expand Down
6 changes: 6 additions & 0 deletions simple-git/src/lib/plugins/simple-git-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@ export interface SimpleGitPluginTypes {
data: Partial<SpawnOptions>;
context: SimpleGitTaskPluginContext & {};
};
'spawn.before': {
data: void;
context: SimpleGitTaskPluginContext & {
kill(reason: Error): void;
};
};
'spawn.after': {
data: void;
context: SimpleGitTaskPluginContext & {
Expand Down
32 changes: 30 additions & 2 deletions simple-git/src/lib/runners/git-executor-chain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,10 +191,26 @@ export class GitExecutorChain implements SimpleGitExecutor {
const stdOut: Buffer[] = [];
const stdErr: Buffer[] = [];

let rejection: Maybe<Error>;

logger.info(`%s %o`, command, args);
logger('%O', spawnOptions);

let rejection = this._beforeSpawn(task, args);
if (rejection) {
return done({
stdOut,
stdErr,
exitCode: 9901,
rejection,
});
}

this._plugins.exec('spawn.before', undefined, {
...pluginContext(task, args),
kill(reason) {
rejection = reason || rejection;
},
});

const spawned = spawn(command, args, spawnOptions);

spawned.stdout!.on(
Expand Down Expand Up @@ -235,6 +251,18 @@ export class GitExecutorChain implements SimpleGitExecutor {
});
});
}

private _beforeSpawn<R>(task: SimpleGitTask<R>, args: string[]) {
let rejection: Maybe<Error>;
this._plugins.exec('spawn.before', undefined, {
...pluginContext(task, args),
kill(reason) {
rejection = reason || rejection;
},
});

return rejection;
}
}

function pluginContext<R>(task: SimpleGitTask<R>, commands: string[]) {
Expand Down
2 changes: 2 additions & 0 deletions simple-git/src/lib/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ export interface GitExecutorResult {
}

export interface SimpleGitPluginConfig {
abort: AbortSignal;

/**
* Configures the events that should be used to determine when the unederlying child process has
* been terminated.
Expand Down
53 changes: 53 additions & 0 deletions simple-git/test/integration/plugin.abort.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { promiseError } from '@kwsites/promise-result';
import {
assertGitError,
createAbortController,
createTestContext,
newSimpleGit,
SimpleGitTestContext,
wait,
} from '@simple-git/test-utils';

import { GitPluginError } from '../..';

describe('timeout', () => {
let context: SimpleGitTestContext;

beforeEach(async () => (context = await createTestContext()));

it('kills processes on abort signal', async () => {
const { controller, abort } = createAbortController();

const threw = promiseError(newSimpleGit(context.root, { abort }).init());

await wait(0);
controller.abort();

assertGitError(await threw, 'Abort signal received', GitPluginError);
});

it('share AbortController across many instances', async () => {
const { controller, abort } = createAbortController();
const upstream = await newSimpleGit(__dirname).revparse('--git-dir');

const repos = await Promise.all('abcdef'.split('').map((p) => context.dir(p)));

await Promise.all(
repos.map((baseDir) => {
const git = newSimpleGit({ baseDir, abort });
if (baseDir.endsWith('a')) {
return promiseError(git.init().then(() => controller.abort()));
}

return promiseError(git.clone(upstream, baseDir));
})
);

const results = await Promise.all(
repos.map((baseDir) => newSimpleGit(baseDir).checkIsRepo())
);

expect(results).toContain(false);
expect(results).toContain(true);
});
});
9 changes: 8 additions & 1 deletion simple-git/test/unit/__mocks__/mock-child-process.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,14 @@ class MockEventTargetImpl implements MockEventTarget {
this.getHandlers(event).forEach((handler) => handler(data));
};

public kill = jest.fn();
public kill = jest.fn((_signal = 'SIGINT') => {
if (this.$emitted('exit')) {
throw new Error('MockEventTarget:kill called on process after exit');
}

this.$emit('exit', 1);
this.$emit('close', 1);
});

public off = jest.fn((event: string, handler: Function) => {
this.delHandler(event, handler);
Expand Down
Loading

0 comments on commit 19029fc

Please sign in to comment.