Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Cancel git operation with CTRL+C signal #536

Draft
wants to merge 18 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
8 changes: 8 additions & 0 deletions binding.gyp
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"targets": [
{
"target_name": "ctrlc",
"sources": [ "lib/ctrlc.cc" ]
}
]
}
129 changes: 129 additions & 0 deletions lib/ctrlc.cc
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
#include <node.h>
#if defined(WIN32) || defined(_WIN32) || defined(__WIN32__) || defined(__NT__)
#include <windows.h>
#endif

namespace ctrlc {

using v8::FunctionCallbackInfo;
using v8::Isolate;
using v8::Local;
using v8::Object;
using v8::String;
using v8::Value;

void SigintWindows(const v8::FunctionCallbackInfo < v8::Value > & args) {
#if defined(WIN32) || defined(_WIN32) || defined(__WIN32__) || defined(__NT__)
v8::Isolate * isolate = args.GetIsolate();
v8::HandleScope scope(isolate);

// Check the number of arguments passed
if (args.Length() != 1) {
v8::Local < v8::String > v8String = v8::String::NewFromUtf8(isolate, "Invalid arguments").ToLocalChecked();
isolate -> ThrowException(v8::Exception::TypeError(v8String));

return;
}

// Check the argument types
if (!args[0] -> IsUint32()) {
v8::Local < v8::String > v8String = v8::String::NewFromUtf8(isolate, "Argument must be a number").ToLocalChecked();
isolate -> ThrowException(v8::Exception::TypeError(v8String));

return;
}

DWORD processId = args[0] -> Uint32Value(isolate -> GetCurrentContext()).ToChecked();

HANDLE hProcess = OpenProcess(SYNCHRONIZE | PROCESS_TERMINATE, FALSE, processId);
if (hProcess == NULL) {
v8::Local<v8::String> v8String = v8::String::NewFromUtf8(isolate, ("Failed to open process. Error code: " + std::to_string(GetLastError())).c_str()).ToLocalChecked();
isolate->ThrowException(v8::Exception::Error(v8String));

return;
}

// Try to attach to console
if (!AttachConsole(processId)) {
v8::Local<v8::String> v8String = v8::String::NewFromUtf8(isolate, ("Failed to attach to console. Error code: " + std::to_string(GetLastError())).c_str()).ToLocalChecked();
isolate->ThrowException(v8::Exception::Error(v8String));

// If attaching to console fails, try sending Ctrl-C event directly
if (!GenerateConsoleCtrlEvent(CTRL_C_EVENT, processId)) {
v8::Local<v8::String> v8String = v8::String::NewFromUtf8(isolate, ("Failed to send Ctrl-C event. Error code: " + std::to_string(GetLastError())).c_str()).ToLocalChecked();
isolate->ThrowException(v8::Exception::Error(v8String));

CloseHandle(hProcess);

return;
} else {
args.GetReturnValue().Set(true);
return;
}
} else {
// Disable Ctrl-C handling for our program
if (!SetConsoleCtrlHandler(NULL, TRUE)) {
v8::Local<v8::String> v8String = v8::String::NewFromUtf8(isolate, ("Failed to disable Ctrl-C handling. Error code: " + std::to_string(GetLastError())).c_str()).ToLocalChecked();
isolate->ThrowException(v8::Exception::Error(v8String));

CloseHandle(hProcess);

return;
}

// Send Ctrl-C event
if (!GenerateConsoleCtrlEvent(CTRL_C_EVENT, 0)) {
v8::Local<v8::String> v8String = v8::String::NewFromUtf8(isolate, ("Failed to send Ctrl-C event. Error code: " + std::to_string(GetLastError())).c_str()).ToLocalChecked();
isolate->ThrowException(v8::Exception::Error(v8String));

// Re-enable Ctrl-C handling
if (!SetConsoleCtrlHandler(NULL, FALSE)) {
v8::Local<v8::String> v8String = v8::String::NewFromUtf8(isolate, ("Failed to re-enable Ctrl-C handling. Error code: " + std::to_string(GetLastError())).c_str()).ToLocalChecked();
isolate->ThrowException(v8::Exception::Error(v8String));
}

FreeConsole();
CloseHandle(hProcess);
return;
} else {
// Wait for process to exit
if (WaitForSingleObject(hProcess, 2000) != WAIT_OBJECT_0) {
v8::Local<v8::String> v8String = v8::String::NewFromUtf8(isolate, "Process did not exit within 2 seconds.").ToLocalChecked();
isolate->ThrowException(v8::Exception::Error(v8String));
}

// Re-enable Ctrl-C handling
if (!SetConsoleCtrlHandler(NULL, FALSE)) {
v8::Local<v8::String> v8String = v8::String::NewFromUtf8(isolate, ("Failed to re-enable Ctrl-C handling. Error code: " + std::to_string(GetLastError())).c_str()).ToLocalChecked();
isolate->ThrowException(v8::Exception::Error(v8String));
}

FreeConsole();
CloseHandle(hProcess);
return;
}
}

// Re-enable Ctrl-C handling
if (!SetConsoleCtrlHandler(NULL, FALSE)) {
v8::Local<v8::String> v8String = v8::String::NewFromUtf8(isolate, "Failed to re-enable Ctrl-C handling").ToLocalChecked();
isolate->ThrowException(v8::Exception::Error(v8String));

CloseHandle(hProcess);

return;
}

FreeConsole();
CloseHandle(hProcess);
args.GetReturnValue().Set(True(isolate));
#endif
}

void Init(Local < Object > exports) {
NODE_SET_METHOD(exports, "sigintWindows", SigintWindows);
}

NODE_MODULE(ctrlc, Init)

} // namespace ctrlc
65 changes: 51 additions & 14 deletions lib/git-process.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import {
GitNotFoundErrorCode,
} from './errors'
import { ChildProcess } from 'child_process'
// @ts-ignore
import * as ctrlc from './ctrlc.node'

import { setupEnvironment } from './git-environment'

Expand Down Expand Up @@ -151,29 +153,31 @@ export class GitProcess {
/**
* Execute a command and read the output using the embedded Git environment.
*
* The returned GitTask will will contain `result`, `setPid`, `cancel`
* The returned GitTask will will contain `result`, `cancel`
* `result` will be a promise, which will reject when the git
* executable fails to launch, in which case the thrown Error will
* have a string `code` property. See `errors.ts` for some of the
* known error codes.
* See the result's `stderr` and `exitCode` for any potential git error
* information.
*
* As for, `setPid(pid)`, this is to set the PID
*
* And `cancel()` will try to cancel the git process
*/
public static execTask(
args: string[],
path: string,
options?: IGitExecutionOptions
): IGitTask {
let pidResolve: {
let gitTaskInfoResolve: {
(arg0: any): void
(value: number | PromiseLike<number | undefined> | undefined): void
(
value: GitTaskInfo | PromiseLike<GitTaskInfo | undefined> | undefined
): void
}
const pidPromise = new Promise<undefined | number>(function (resolve) {
pidResolve = resolve
const gitTaskInfoPromise = new Promise<undefined | GitTaskInfo>(function (
resolve
) {
gitTaskInfoResolve = resolve
})

let result = new GitTask(
Expand Down Expand Up @@ -258,7 +262,13 @@ export class GitProcess {
}
)

pidResolve(spawnedProcess.pid)
gitTaskInfoResolve({
pid: spawnedProcess.pid,
gitTaskActionType:
args[0] === 'clone'
? GitTaskActionType.clone
: GitTaskActionType.general,
} as GitTaskInfo)

ignoreClosedInputStream(spawnedProcess)

Expand All @@ -275,7 +285,7 @@ export class GitProcess {
options.processCallback(spawnedProcess)
}
}),
pidPromise
gitTaskInfoPromise
)

return result
Expand Down Expand Up @@ -357,6 +367,16 @@ function ignoreClosedInputStream({ stdin }: ChildProcess) {
})
}

enum GitTaskActionType {
general,
clone,
}

interface GitTaskInfo {
pid: number
gitTaskActionType: GitTaskActionType
}

export enum GitTaskCancelResult {
successfulCancel,
processAlreadyEnded,
Expand All @@ -373,13 +393,16 @@ export interface IGitTask {
}

class GitTask implements IGitTask {
constructor(result: Promise<IGitResult>, pid: Promise<number | undefined>) {
constructor(
result: Promise<IGitResult>,
gitTaskInfo: Promise<GitTaskInfo | undefined>
) {
this.result = result
this.pid = pid
this.gitTaskInfo = gitTaskInfo
this.processEnded = false
}

private pid: Promise<number | undefined>
private gitTaskInfo: Promise<GitTaskInfo | undefined>
/** Process may end because process completed or process errored. Either way, we can no longer cancel it. */
private processEnded: boolean

Expand All @@ -394,14 +417,28 @@ class GitTask implements IGitTask {
return GitTaskCancelResult.processAlreadyEnded
}

const pid = await this.pid
const gitTaskInfo = await this.gitTaskInfo

if (!gitTaskInfo) {
return GitTaskCancelResult.failedToCancel
}

const pid = gitTaskInfo.pid
const gitTaskActionType = gitTaskInfo.gitTaskActionType

if (pid === undefined) {
return GitTaskCancelResult.noProcessIdDefined
}

try {
kill(pid)
if (
process.platform === 'win32' &&
gitTaskActionType === GitTaskActionType.clone
) {
ctrlc.sigintWindows(pid)
} else {
kill(pid)
}
return GitTaskCancelResult.successfulCancel
} catch (e) {}

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
"test:slow": "cross-env LOCAL_GIT_DIRECTORY=./git/ jest --runInBand --silent --config ./jest.slow.config.js",
"test:external": "jest --runInBand --silent --config ./jest.external.config.js",
"download-git": "node ./script/download-git.js",
"postinstall": "node ./script/download-git.js",
"postinstall": "node ./script/download-git.js && node ./script/copy-native-build.js",
"prettify": "prettier \"{examples,lib,script,test}/**/*.ts\" --write",
"is-it-pretty": "prettier --check \"{examples,lib,script,test}/**/*.ts\"",
"update-embedded-git": "node ./script/update-embedded-git.js"
Expand Down
9 changes: 9 additions & 0 deletions script/copy-native-build.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
const fs = require('fs');

const sourceFilePath = 'build/Release/ctrlc.node';
const destinationFilePath = 'lib/ctrlc.node';

fs.copyFile(sourceFilePath, destinationFilePath, (err) => {
if (err) throw err;
console.log('ctrlc.node copied to lib successfully!');
});
30 changes: 20 additions & 10 deletions test/fast/git-process-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,32 +21,42 @@ import { setupNoAuth } from '../slow/auth'

const temp = require('temp').track()

const sleep = (seconds: number) =>
new Promise(resolve => setTimeout(resolve, 1000 * seconds))

describe('git-process', () => {
it('cannot cancel already finished git command', async () => {
const testRepoPath = temp.mkdirSync('desktop-git-do-nothing')
const task = GitProcess.execTask(['--version'], testRepoPath)
await task.result

const cancelResult = await task.cancel()
expect(cancelResult).toBe(GitTaskCancelResult.processAlreadyEnded)
})

it('can cancel in-progress git command', async () => {
const testRepoPath = temp.mkdirSync('desktop-git-clone-valid')
const options = {
env: setupNoAuth(),
}
// intentionally choosing a large Git repository so that it won't be cloned in less than one second
const task = GitProcess.execTask(
['clone', '--', 'https://github.com/shiftkey/friendly-bassoon.git', '.'],
['clone', '--', 'https://github.com/desktop/desktop.git', '.'],
testRepoPath,
options
)

// wait for 1 second before we cancel it so that all of Git's child processes are created
await sleep(1)
const cancelResult = await task.cancel()
try {
await task.result
} catch {}
expect(cancelResult).toBe(GitTaskCancelResult.successfulCancel)
})

it('cannot cancel already finished git command', async () => {
const testRepoPath = temp.mkdirSync('desktop-git-do-nothing')
const task = GitProcess.execTask(['--version'], testRepoPath)
await task.result

const cancelResult = await task.cancel()
expect(cancelResult).toBe(GitTaskCancelResult.processAlreadyEnded)
const clonedListDirectory = fs.readdirSync(testRepoPath)
// making that the Git clone process was properly cancelled and the target directory is empty
expect(clonedListDirectory).toEqual([])
expect(cancelResult).toBe(GitTaskCancelResult.successfulCancel)
})

it('can launch git', async () => {
Expand Down