Skip to content
Open
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,4 @@ build/

# git local install
git/
lib/ctrlc.node
1 change: 1 addition & 0 deletions binding.gyp
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"targets": [{"target_name": "ctrlc", "sources": ["lib/ctrlc.cc"]}]}
146 changes: 146 additions & 0 deletions lib/ctrlc.cc
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
#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))
{
DWORD error = GetLastError();

// If already attached to a console or no console, try direct termination
if (error == ERROR_ACCESS_DENIED || error == ERROR_INVALID_HANDLE)
{
// Try to send Ctrl-C event directly to the process group
if (!GenerateConsoleCtrlEvent(CTRL_C_EVENT, processId))
{
CloseHandle(hProcess);
args.GetReturnValue().Set(false);
return;
}

CloseHandle(hProcess);
args.GetReturnValue().Set(true);
return;
}

v8::Local<v8::String> v8String = v8::String::NewFromUtf8(isolate, ("Failed to attach to console. Error code: " + std::to_string(error)).c_str()).ToLocalChecked();
isolate->ThrowException(v8::Exception::Error(v8String));
CloseHandle(hProcess);
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 (but don't fail if it takes longer)
WaitForSingleObject(hProcess, 2000);

// Re-enable Ctrl-C handling
SetConsoleCtrlHandler(NULL, FALSE);

FreeConsole();
CloseHandle(hProcess);
args.GetReturnValue().Set(true);
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(v8::Boolean::New(isolate, true));
#endif
}

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

NODE_MODULE(ctrlc, Init)

} // namespace ctrlc
3 changes: 3 additions & 0 deletions lib/exec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { ChildProcess, execFile, ExecFileOptions } from 'child_process'
import { setupEnvironment } from './git-environment'
import { ExecError } from './errors'
import { ignoreClosedInputStream } from './ignore-closed-input-stream'
import { processTerminator } from './process-termination'

export interface IGitResult {
/** The standard output from git. */
Expand Down Expand Up @@ -173,6 +174,8 @@ export function exec(

ignoreClosedInputStream(cp)

processTerminator(cp)

if (options?.stdin !== undefined && cp.stdin) {
// See https://github.com/nodejs/node/blob/7b5ffa46fe4d2868c1662694da06eb55ec744bde/test/parallel/test-stdin-pipe-large.js
if (options.stdinEncoding) {
Expand Down
46 changes: 46 additions & 0 deletions lib/process-termination.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { ChildProcess } from 'child_process'

let ctrlc: { sigintWindows: (pid: number) => boolean } | undefined

// Only load the native addon on Windows and when it's available
if (process.platform === 'win32') {
try {
// @ts-ignore - Dynamic import of native module
ctrlc = require('./ctrlc.node')
} catch (error) {
// Native addon not available, fall back to regular termination
console.warn(
'dugite: Native Ctrl+C addon not available, using fallback termination method'
)
}
}

/**
* Kill method to use Ctrl+C on Windows when available.
* This is a monkey-patche approach for the ChildProcess.kill method to keep the API consistent.
*/
export function processTerminator(childProcess: ChildProcess): void {
if (process.platform !== 'win32' || !ctrlc) {
return
}

const originalKill = childProcess.kill.bind(childProcess)

childProcess.kill = function (signal?: NodeJS.Signals | number): boolean {
const pid = childProcess.pid

// Only try Ctrl+C for explicit SIGTERM/SIGINT signals on Windows
if (pid && (signal === 'SIGTERM' || signal === 'SIGINT')) {
try {
if (ctrlc!.sigintWindows(pid)) {
return true
}
} catch (_) {
// Fall through to original kill
}
}

// Use original kill method being used as fallback here
return originalKill(signal)
}
}
3 changes: 3 additions & 0 deletions lib/spawn.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { ignoreClosedInputStream } from './ignore-closed-input-stream'
import { setupEnvironment } from './git-environment'
import { processTerminator } from './process-termination'
import { spawn as _spawn } from 'child_process'

/**
Expand Down Expand Up @@ -28,5 +29,7 @@ export function spawn(args: string[], path: string, opts?: IGitSpawnOptions) {

ignoreClosedInputStream(spawnedProcess)

processTerminator(spawnedProcess)

return spawnedProcess
}
10 changes: 7 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,15 @@
"typings": "./build/lib/index.d.ts",
"scripts": {
"clean": "node script/clean.js",
"build": "yarn clean && tsc -p ./tsconfig.json && tsc -p ./examples/tsconfig.json",
"build": "yarn clean && yarn native:all && tsc -p ./tsconfig.json && tsc -p ./examples/tsconfig.json",
"native:all": "yarn native:build && yarn native:copy",
"native:build": "node-gyp rebuild",
"native:copy": "node ./script/copy-native.js",
"prepack": "yarn build && yarn test",
"postpublish": "git push --follow-tags",
"test": "node script/test.mjs",
"download-git": "node ./script/download-git.js",
"postinstall": "node ./script/download-git.js",
"postinstall": "node ./script/download-git.js && yarn native:all",
"prettify": "prettier \"{examples,lib,script,test}/**/*.{ts,js,mjs}\" --write",
"is-it-pretty": "prettier \"{examples,lib,script,test}/**/*.{ts,js,mjs}\" --check",
"update-embedded-git": "node ./script/update-embedded-git.js"
Expand All @@ -36,9 +39,10 @@
"devDependencies": {
"@types/node": "20",
"@types/progress": "^2.0.1",
"node-gyp": "^11.0.0",
"node-test-github-reporter": "^1.2.0",
"prettier": "^3.3.1",
"tsx": "^4.10.5",
"typescript": "^5.4.5"
}
}
}
23 changes: 23 additions & 0 deletions script/copy-native.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
const fs = require('fs')
const path = require('path')

const rootDir = path.join(__dirname, '..') // <root>

try {
// Copy to <root>/lib/ctrlc.node
fs.copyFileSync(
path.join(rootDir, 'build', 'Release', 'ctrlc.node'),
path.join(rootDir, 'lib', 'ctrlc.node')
)

fs.mkdirSync(path.join(rootDir, 'build', 'lib'), { recursive: true })

// Copy to <root>/build/lib/ctrlc.node
// attention if we switch swithc between `lib` or `build/lib` or `dist`, we need to update this
fs.copyFileSync(
path.join(rootDir, 'build', 'Release', 'ctrlc.node'),
path.join(rootDir, 'build', 'lib', 'ctrlc.node')
)
} catch (e) {
console.warn('Warning: Could not copy native addon:', e.message)
}
53 changes: 53 additions & 0 deletions test/slow/git-process-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,59 @@ describe('git-process', () => {
server.close()
}
})

it('can cancel clone operation with AbortController', async t => {
const testRepoPath = await createTestDir(t, 'desktop-git-clone-cancel')
const controller = new AbortController()

// Cancel after 2 second to test cancellation during clone
// Cause those 4, 5 process creation takes a bit of time
const cancelTimeout = setTimeout(() => {
console.log('Cancelling git clone operation...')
controller.abort()
}, 2000)

try {
// Using a real repository URL that's large enough to not complete instantly
const result = await exec(
[
'clone',
'--depth',
'1',
'http://github.com/maifeeulasad/maifeeulasad.github.io.git',
'vscode-clone',
],
testRepoPath,
{
signal: controller.signal,
processCallback: process => {
console.log(`Started git clone with PID: ${process.pid}`)
},
}
)

// If we get here, the clone completed before cancellation
console.log('Clone completed before cancellation')
verify(result, r => {
assert.equal(r.exitCode, 0)
})
} catch (error: any) {
// This is expected when cancellation works
console.log(`Git clone was cancelled: ${error.code}`)
assert.equal(
error.code,
'ABORT_ERR',
'Expected ABORT_ERR when clone is cancelled'
)

// Wait 3 seconds after cancellation to allow file handles to be released
// 4,5 process takes a while to release as there are rolling file handles
console.log('Waiting 3 seconds for process cleanup...')
await new Promise(resolve => setTimeout(resolve, 3000))
} finally {
clearTimeout(cancelTimeout)
}
})
})

describe('fetch', () => {
Expand Down
Loading
Loading