fix: replace domain-specific exit handler with generic signal handler registry (#299)

* fix: replace domain-specific exit handler with generic signal handler registry

Rewrite exitHandler.ts as a generic, domain-agnostic exit signal module
that exports onExitSignal(handler) returning a dispose function.

- subprocess.ts now registers via onExitSignal instead of direct
  process.on(SIGINT/SIGTERM) calls
- resolveTokens registers a signal handler that captures tokens by
  closure, fixing the race condition where the exit handler would
  read the wrong token after disposal
- Remove setupExitHandler and runCleanup — domain cleanup is handled
  by post.ts + await using

Co-authored-by: Cursor <cursoragent@cursor.com>

* tweaks

* simplify handler installation

* extract to util

* fix race in dispose

* wrap dispose body in try/finally to ensure disposingRef always settles

---------

Co-authored-by: Mateusz Burzyński <mateuszburzynski@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: pullfrog[bot] <226033991+pullfrog[bot]@users.noreply.github.com>
This commit is contained in:
pullfrog[bot]
2026-02-20 10:23:56 +00:00
committed by pullfrog[bot]
parent 70f1c47a28
commit ee100354da
10 changed files with 198 additions and 317 deletions
+77 -129
View File
@@ -148,11 +148,11 @@ var require_command = __commonJS({
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.issue = exports.issueCommand = void 0;
var os = __importStar(__require("os"));
var os2 = __importStar(__require("os"));
var utils_1 = require_utils();
function issueCommand(command, properties, message) {
const cmd = new Command(command, properties, message);
process.stdout.write(cmd.toString() + os.EOL);
process.stdout.write(cmd.toString() + os2.EOL);
}
exports.issueCommand = issueCommand;
function issue3(name, message = "") {
@@ -236,7 +236,7 @@ var require_file_command = __commonJS({
exports.prepareKeyValueMessage = exports.issueFileCommand = void 0;
var crypto2 = __importStar(__require("crypto"));
var fs3 = __importStar(__require("fs"));
var os = __importStar(__require("os"));
var os2 = __importStar(__require("os"));
var utils_1 = require_utils();
function issueFileCommand(command, message) {
const filePath = process.env[`GITHUB_${command}`];
@@ -246,7 +246,7 @@ var require_file_command = __commonJS({
if (!fs3.existsSync(filePath)) {
throw new Error(`Missing file at path: ${filePath}`);
}
fs3.appendFileSync(filePath, `${(0, utils_1.toCommandValue)(message)}${os.EOL}`, {
fs3.appendFileSync(filePath, `${(0, utils_1.toCommandValue)(message)}${os2.EOL}`, {
encoding: "utf8"
});
}
@@ -260,7 +260,7 @@ var require_file_command = __commonJS({
if (convertedValue.includes(delimiter)) {
throw new Error(`Unexpected input: value should not contain the delimiter "${delimiter}"`);
}
return `${key}<<${delimiter}${os.EOL}${convertedValue}${os.EOL}${delimiter}`;
return `${key}<<${delimiter}${os2.EOL}${convertedValue}${os2.EOL}${delimiter}`;
}
exports.prepareKeyValueMessage = prepareKeyValueMessage;
}
@@ -11280,7 +11280,7 @@ var require_RetryHandler = __commonJS({
return diff;
}
var RetryHandler = class _RetryHandler {
constructor(opts, handlers) {
constructor(opts, handlers2) {
const { retryOptions, ...dispatchOpts } = opts;
const {
// Retry scoped
@@ -11295,8 +11295,8 @@ var require_RetryHandler = __commonJS({
retryAfter,
statusCodes
} = retryOptions ?? {};
this.dispatch = handlers.dispatch;
this.handler = handlers.handler;
this.dispatch = handlers2.dispatch;
this.handler = handlers2.handler;
this.opts = dispatchOpts;
this.abort = null;
this.aborted = false;
@@ -17513,7 +17513,7 @@ var require_lib = __commonJS({
}
exports.isHttps = isHttps;
var HttpClient = class {
constructor(userAgent2, handlers, requestOptions) {
constructor(userAgent2, handlers2, requestOptions) {
this._ignoreSslError = false;
this._allowRedirects = true;
this._allowRedirectDowngrade = false;
@@ -17523,7 +17523,7 @@ var require_lib = __commonJS({
this._keepAlive = false;
this._disposed = false;
this.userAgent = userAgent2;
this.handlers = handlers || [];
this.handlers = handlers2 || [];
this.requestOptions = requestOptions;
if (requestOptions) {
if (requestOptions.ignoreSslError != null) {
@@ -18983,7 +18983,7 @@ var require_toolrunner = __commonJS({
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.argStringToArray = exports.ToolRunner = void 0;
var os = __importStar(__require("os"));
var os2 = __importStar(__require("os"));
var events = __importStar(__require("events"));
var child = __importStar(__require("child_process"));
var path3 = __importStar(__require("path"));
@@ -19038,12 +19038,12 @@ var require_toolrunner = __commonJS({
_processLineBuffer(data, strBuffer, onLine) {
try {
let s = strBuffer + data.toString();
let n = s.indexOf(os.EOL);
let n = s.indexOf(os2.EOL);
while (n > -1) {
const line = s.substring(0, n);
onLine(line);
s = s.substring(n + os.EOL.length);
n = s.indexOf(os.EOL);
s = s.substring(n + os2.EOL.length);
n = s.indexOf(os2.EOL);
}
return s;
} catch (err) {
@@ -19212,7 +19212,7 @@ var require_toolrunner = __commonJS({
}
const optionsNonNull = this._cloneExecOptions(this.options);
if (!optionsNonNull.silent && optionsNonNull.outStream) {
optionsNonNull.outStream.write(this._getCommandString(optionsNonNull) + os.EOL);
optionsNonNull.outStream.write(this._getCommandString(optionsNonNull) + os2.EOL);
}
const state = new ExecState(optionsNonNull, this.toolPath);
state.on("debug", (message) => {
@@ -19700,7 +19700,7 @@ var require_core = __commonJS({
var command_1 = require_command();
var file_command_1 = require_file_command();
var utils_1 = require_utils();
var os = __importStar(__require("os"));
var os2 = __importStar(__require("os"));
var path3 = __importStar(__require("path"));
var oidc_utils_1 = require_oidc_utils();
var ExitCode;
@@ -19768,7 +19768,7 @@ Support boolean input list: \`true | True | TRUE | false | False | FALSE\``);
if (filePath) {
return (0, file_command_1.issueFileCommand)("OUTPUT", (0, file_command_1.prepareKeyValueMessage)(name, value2));
}
process.stdout.write(os.EOL);
process.stdout.write(os2.EOL);
(0, command_1.issueCommand)("set-output", { name }, (0, utils_1.toCommandValue)(value2));
}
exports.setOutput = setOutput2;
@@ -19802,7 +19802,7 @@ Support boolean input list: \`true | True | TRUE | false | False | FALSE\``);
}
exports.notice = notice;
function info2(message) {
process.stdout.write(message + os.EOL);
process.stdout.write(message + os2.EOL);
}
exports.info = info2;
function startGroup3(name) {
@@ -135779,6 +135779,31 @@ import { join } from "node:path";
var core3 = __toESM(require_core(), 1);
import assert2 from "node:assert/strict";
// utils/exitHandler.ts
import os from "node:os";
var handlers = /* @__PURE__ */ new Set();
var installed = false;
function onExitSignal(handler2) {
installSignalHandlers();
handlers.add(handler2);
return () => {
handlers.delete(handler2);
};
}
function installSignalHandlers() {
if (installed) return;
installed = true;
async function handleSignal(signal) {
await Promise.allSettled([...handlers].map((h) => Promise.try(h, signal)));
exitWithSignal(signal);
}
process.on("SIGINT", handleSignal);
process.on("SIGTERM", handleSignal);
}
function exitWithSignal(signal) {
process.exit(128 + os.constants.signals[signal]);
}
// utils/github.ts
var core2 = __toESM(require_core(), 1);
import { createSign } from "node:crypto";
@@ -139728,18 +139753,31 @@ async function resolveTokens(params) {
log.info("\xBB acquired full MCP token");
const revertGithubToken = setEnvironmentVariable("GITHUB_TOKEN", mcpToken);
mcpTokenValue = mcpToken;
return {
gitToken,
mcpToken,
async [Symbol.asyncDispose]() {
let disposingRef;
const dispose = async () => {
if (disposingRef) {
return disposingRef.promise;
}
disposingRef = Promise.withResolvers();
try {
mcpTokenValue = void 0;
revertGithubToken();
await Promise.all([
revokeGitHubInstallationToken(gitToken),
revokeGitHubInstallationToken(mcpToken)
]);
} finally {
removeSignalHandler();
disposingRef.resolve();
disposingRef = void 0;
}
};
const removeSignalHandler = onExitSignal(dispose);
return {
gitToken,
mcpToken,
[Symbol.asyncDispose]: dispose
};
}
function getGitHubInstallationToken() {
assert2(mcpTokenValue, "tokens not set. call resolveTokens first.");
@@ -140647,13 +140685,13 @@ function createProcessOutputActivityTimeout(ctx) {
var activeChildren = /* @__PURE__ */ new Map();
var externalSignalHandler = null;
function trackChild(options) {
installSignalHandler();
activeChildren.set(options.child, options.killGroup ?? false);
}
function untrackChild(child) {
activeChildren.delete(child);
}
function killTrackedChildren() {
const count = activeChildren.size;
for (const entry of activeChildren) {
const child = entry[0];
const killGroup = entry[1];
@@ -140666,34 +140704,27 @@ function killTrackedChildren() {
}
child.kill("SIGKILL");
}
return count;
}
function cleanupAndExit(signal) {
const count = killTrackedChildren();
if (count > 0) {
log.info(`\xBB received ${signal}, killing ${count} subprocess(es)...`);
}
setTimeout(() => process.exit(1), 500).unref();
process.exit(1);
}
function handleSignal(signal) {
if (externalSignalHandler) {
externalSignalHandler(signal);
return;
}
cleanupAndExit(signal);
}
var handlersInstalled = false;
function installSignalHandlers() {
function installSignalHandler() {
if (handlersInstalled) return;
handlersInstalled = true;
process.on("SIGINT", () => handleSignal("SIGINT"));
process.on("SIGTERM", () => handleSignal("SIGTERM"));
onExitSignal((signal) => {
if (externalSignalHandler) {
externalSignalHandler(signal);
return;
}
const count = activeChildren.size;
if (count > 0) {
log.info(`\xBB received ${signal}, killing ${count} subprocess(es)...`);
}
killTrackedChildren();
});
}
async function spawn2(options) {
const { cmd, args: args2, env: env2, input, timeout, cwd, stdio, onStdout, onStderr } = options;
const activityTimeoutMs = options.activityTimeout ?? DEFAULT_ACTIVITY_TIMEOUT_MS;
installSignalHandlers();
installSignalHandler();
const startTime = performance3.now();
let stdoutBuffer = "";
let stderrBuffer = "";
@@ -141302,7 +141333,6 @@ function stripExistingFooter(body) {
}
// mcp/comment.ts
var LEAPING_INTO_ACTION_PREFIX = "Leaping into action";
async function buildCommentFooter({
agent: agent2,
octokit,
@@ -145378,10 +145408,10 @@ var cursorEffortModels = {
max: "opus-4.5-thinking"
};
async function installCursor() {
const os = process.platform === "darwin" ? "darwin" : "linux";
const os2 = process.platform === "darwin" ? "darwin" : "linux";
const arch = process.arch === "arm64" ? "arm64" : "x64";
return await installFromDirectTarball({
url: `https://downloads.cursor.com/lab/${CURSOR_CLI_VERSION}/${os}/${arch}/agent-cli-package.tar.gz`,
url: `https://downloads.cursor.com/lab/${CURSOR_CLI_VERSION}/${os2}/${arch}/agent-cli-package.tar.gz`,
executablePath: "cursor-agent",
stripComponents: 1
});
@@ -146495,83 +146525,6 @@ ${ctx.error}` : ctx.error;
ctx.toolState.wasUpdated = true;
}
// utils/exitHandler.ts
function buildErrorCommentBody(params) {
const workflowRunLink = params.runId ? `[workflow run logs](https://github.com/${params.owner}/${params.repo}/actions/runs/${params.runId})` : "workflow run logs";
const errorMessage = params.isCancellation ? `This run was cancelled \u{1F6D1}
The workflow was cancelled before completion. Please check the ${workflowRunLink} for details.` : `This run croaked \u{1F635}
The workflow encountered an error before any progress could be reported. Please check the ${workflowRunLink} for details.`;
const footer = buildPullfrogFooter({
triggeredBy: true,
workflowRun: params.runId ? { owner: params.owner, repo: params.repo, runId: params.runId } : void 0
});
return `${errorMessage}${footer}`;
}
var cleanupFn;
function setupExitHandler(toolState) {
let hasCleanedUp = false;
async function cleanup(isCancellation) {
if (hasCleanedUp) {
return;
}
hasCleanedUp = true;
const token = process.env.GITHUB_TOKEN;
const commentId = toolState.progressCommentId;
const wasUpdated = toolState.wasUpdated === true;
if (token && commentId && !wasUpdated) {
try {
const repoContext = parseRepoContext();
const octokit = createOctokit(token);
const existingComment = await octokit.rest.issues.getComment({
owner: repoContext.owner,
repo: repoContext.name,
comment_id: commentId
});
const commentBody = existingComment.data.body || "";
if (commentBody.startsWith(LEAPING_INTO_ACTION_PREFIX)) {
const runId = process.env.GITHUB_RUN_ID;
const body = buildErrorCommentBody({
owner: repoContext.owner,
repo: repoContext.name,
runId,
isCancellation
});
await octokit.rest.issues.updateComment({
owner: repoContext.owner,
repo: repoContext.name,
comment_id: commentId,
body
});
log.info("\xBB updated progress comment with error message");
}
} catch {
}
}
if (token) {
try {
await revokeGitHubInstallationToken(token);
log.debug("\xBB installation token revoked");
} catch {
}
}
}
cleanupFn = cleanup;
function handleSignal2() {
log.info("\xBB workflow cancelled, cleaning up...");
cleanup(true).finally(() => process.exit(1));
}
process.on("SIGINT", handleSignal2);
process.on("SIGTERM", handleSignal2);
}
async function runCleanup() {
try {
await cleanupFn?.(false);
} catch {
}
}
// utils/normalizeEnv.ts
function maskValue(value2) {
if (value2 && typeof value2 === "string" && value2.trim().length > 0) {
@@ -146981,7 +146934,6 @@ async function main() {
const toolState = initToolState({
progressCommentId: typeof resolvedPromptInput !== "string" ? resolvedPromptInput.progressCommentId : void 0
});
setupExitHandler(toolState);
resolveGit();
const jobToken = getJobToken();
const initialOctokit = createOctokit(jobToken);
@@ -147141,9 +147093,7 @@ ${instructions.user}` : null,
error: errorMessage
};
} finally {
if (activityTimeout) {
activityTimeout.stop();
}
activityTimeout?.stop();
}
} catch (_2) {
var _error2 = _2, _hasError2 = true;
@@ -147166,8 +147116,6 @@ async function run() {
} catch (error49) {
const errorMessage = error49 instanceof Error ? error49.message : "Unknown error occurred";
core5.setFailed(`Action failed: ${errorMessage}`);
} finally {
await runCleanup();
}
}
await run();
-3
View File
@@ -6,7 +6,6 @@
import * as core from "@actions/core";
import { main } from "./main.ts";
import { runCleanup } from "./utils/exitHandler.ts";
async function run(): Promise<void> {
try {
@@ -22,8 +21,6 @@ async function run(): Promise<void> {
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
core.setFailed(`Action failed: ${errorMessage}`);
} finally {
await runCleanup();
}
}
+1 -6
View File
@@ -12,7 +12,6 @@ import { validateAgentApiKey } from "./utils/apiKeys.ts";
import { resolveBody } from "./utils/body.ts";
import { log, writeSummary } from "./utils/cli.ts";
import { reportErrorToComment } from "./utils/errorReport.ts";
import { setupExitHandler } from "./utils/exitHandler.ts";
import { resolveGit } from "./utils/gitAuth.ts";
import { createOctokit } from "./utils/github.ts";
import { resolveInstructions } from "./utils/instructions.ts";
@@ -52,8 +51,6 @@ export async function main(): Promise<MainResult> {
typeof resolvedPromptInput !== "string" ? resolvedPromptInput.progressCommentId : undefined,
});
setupExitHandler(toolState);
// resolve and fingerprint git binary before any agent code runs
resolveGit();
@@ -247,8 +244,6 @@ export async function main(): Promise<MainResult> {
error: errorMessage,
};
} finally {
if (activityTimeout) {
activityTimeout.stop();
}
activityTimeout?.stop();
}
}
+29 -31
View File
@@ -41198,37 +41198,8 @@ var ReplyToReviewComment = type({
)
});
// utils/token.ts
var core3 = __toESM(require_core(), 1);
function getJobToken() {
const inputToken = core3.getInput("token");
if (inputToken) {
return inputToken;
}
const fallbackToken = process.env.GH_TOKEN || process.env.GITHUB_TOKEN;
if (fallbackToken) {
return fallbackToken;
}
throw new Error("token input is required");
}
// utils/exitHandler.ts
function buildErrorCommentBody(params) {
const workflowRunLink = params.runId ? `[workflow run logs](https://github.com/${params.owner}/${params.repo}/actions/runs/${params.runId})` : "workflow run logs";
const errorMessage = params.isCancellation ? `This run was cancelled \u{1F6D1}
The workflow was cancelled before completion. Please check the ${workflowRunLink} for details.` : `This run croaked \u{1F635}
The workflow encountered an error before any progress could be reported. Please check the ${workflowRunLink} for details.`;
const footer = buildPullfrogFooter({
triggeredBy: true,
workflowRun: params.runId ? { owner: params.owner, repo: params.repo, runId: params.runId } : void 0
});
return `${errorMessage}${footer}`;
}
// utils/payload.ts
var core4 = __toESM(require_core(), 1);
var core3 = __toESM(require_core(), 1);
// external.ts
var agentsManifest = {
@@ -41399,7 +41370,7 @@ var Inputs = type({
"cwd?": type.string.or("undefined")
});
function resolvePromptInput() {
const prompt = core4.getInput("prompt", { required: true });
const prompt = core3.getInput("prompt", { required: true });
let parsed2;
try {
parsed2 = JSON.parse(prompt);
@@ -41414,8 +41385,35 @@ function resolvePromptInput() {
return jsonPayload;
}
// utils/token.ts
var core4 = __toESM(require_core(), 1);
function getJobToken() {
const inputToken = core4.getInput("token");
if (inputToken) {
return inputToken;
}
const fallbackToken = process.env.GH_TOKEN || process.env.GITHUB_TOKEN;
if (fallbackToken) {
return fallbackToken;
}
throw new Error("token input is required");
}
// utils/postCleanup.ts
var SHOULD_CHECK_REASON = true;
function buildErrorCommentBody(params) {
const workflowRunLink = params.runId ? `[workflow run logs](https://github.com/${params.owner}/${params.repo}/actions/runs/${params.runId})` : "workflow run logs";
const errorMessage = params.isCancellation ? `This run was cancelled \u{1F6D1}
The workflow was cancelled before completion. Please check the ${workflowRunLink} for details.` : `This run croaked \u{1F635}
The workflow encountered an error before any progress could be reported. Please check the ${workflowRunLink} for details.`;
const footer = buildPullfrogFooter({
triggeredBy: true,
workflowRun: params.runId ? { owner: params.owner, repo: params.repo, runId: params.runId } : void 0
});
return `${errorMessage}${footer}`;
}
async function validateStuckProgressComment(promptInput, octokit, owner, repo) {
if (!promptInput?.progressCommentId) {
log.info("[post] no progressCommentId in prompt input, skipping cleanup");
+1 -6
View File
@@ -5,11 +5,7 @@ import { config } from "dotenv";
import { runInDocker } from "../utils/docker.ts";
import { ensureGitHubToken } from "../utils/github.ts";
import { isInsideDocker } from "../utils/globals.ts";
import {
installSignalHandlers,
killTrackedChildren,
setSignalHandler,
} from "../utils/subprocess.ts";
import { killTrackedChildren, setSignalHandler } from "../utils/subprocess.ts";
import {
type AgentResult,
agents,
@@ -482,7 +478,6 @@ async function main(): Promise<void> {
}
setSignalHandler(handleCancel);
installSignalHandlers();
// run tests with limited concurrency to avoid overwhelming agent APIs
const maxConcurrency = 5;
+1 -3
View File
@@ -5,7 +5,7 @@ import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { agentsManifest } from "../external.ts";
import type { Inputs } from "../main.ts";
import { installSignalHandlers, trackChild, untrackChild } from "../utils/subprocess.ts";
import { trackChild, untrackChild } from "../utils/subprocess.ts";
const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -165,8 +165,6 @@ const DEFAULT_TEST_TIMEOUT = "10m";
// run agent and stream output with prefix labels
// note: activity timeout is enforced in action main and subprocess utils
export async function runAgentStreaming(options: RunStreamingOptions): Promise<AgentResult> {
installSignalHandlers();
return new Promise((resolve) => {
const chunks: Buffer[] = [];
const prefix = getPrefix({ test: options.test, agent: options.agent });
+22 -106
View File
@@ -1,119 +1,35 @@
import { LEAPING_INTO_ACTION_PREFIX } from "../mcp/comment.ts";
import type { ToolState } from "../mcp/server.ts";
import { buildPullfrogFooter } from "./buildPullfrogFooter.ts";
import { log } from "./cli.ts";
import { createOctokit, parseRepoContext } from "./github.ts";
import { revokeGitHubInstallationToken } from "./token.ts";
import os from "node:os";
type ExitSignalHandler = (signal: "SIGINT" | "SIGTERM") => void | Promise<void>;
const handlers = new Set<ExitSignalHandler>();
let installed = false;
/**
* Build error comment body with error message and footer
* Register a handler to run when the process receives SIGINT or SIGTERM.
* Returns a dispose function that removes the handler.
*/
export function buildErrorCommentBody(params: {
owner: string;
repo: string;
runId: string | undefined;
isCancellation: boolean;
}): string {
const workflowRunLink = params.runId
? `[workflow run logs](https://github.com/${params.owner}/${params.repo}/actions/runs/${params.runId})`
: "workflow run logs";
const errorMessage = params.isCancellation
? `This run was cancelled 🛑\n\nThe workflow was cancelled before completion. Please check the ${workflowRunLink} for details.`
: `This run croaked 😵\n\nThe workflow encountered an error before any progress could be reported. Please check the ${workflowRunLink} for details.`;
const footer = buildPullfrogFooter({
triggeredBy: true,
workflowRun: params.runId
? { owner: params.owner, repo: params.repo, runId: params.runId }
: undefined,
});
return `${errorMessage}${footer}`;
export function onExitSignal(handler: ExitSignalHandler): () => void {
installSignalHandlers();
handlers.add(handler);
return () => {
handlers.delete(handler);
};
}
let cleanupFn: ((isCancellation: boolean) => Promise<void>) | undefined;
function installSignalHandlers(): void {
if (installed) return;
installed = true;
export function setupExitHandler(toolState: ToolState): void {
let hasCleanedUp = false;
async function cleanup(isCancellation: boolean): Promise<void> {
if (hasCleanedUp) {
return;
}
hasCleanedUp = true;
const token = process.env.GITHUB_TOKEN;
const commentId = toolState.progressCommentId;
const wasUpdated = toolState.wasUpdated === true;
// update progress comment if it was never updated (still shows "leaping into action")
if (token && commentId && !wasUpdated) {
try {
const repoContext = parseRepoContext();
const octokit = createOctokit(token);
const existingComment = await octokit.rest.issues.getComment({
owner: repoContext.owner,
repo: repoContext.name,
comment_id: commentId,
});
const commentBody = existingComment.data.body || "";
// only update if comment still shows the initial "leaping into action" message
if (commentBody.startsWith(LEAPING_INTO_ACTION_PREFIX)) {
const runId = process.env.GITHUB_RUN_ID;
const body = buildErrorCommentBody({
owner: repoContext.owner,
repo: repoContext.name,
runId,
isCancellation,
});
await octokit.rest.issues.updateComment({
owner: repoContext.owner,
repo: repoContext.name,
comment_id: commentId,
body,
});
log.info("» updated progress comment with error message");
}
} catch {
// ignore errors during cleanup
}
}
// revoke token
if (token) {
try {
await revokeGitHubInstallationToken(token);
log.debug("» installation token revoked");
} catch {
// ignore errors during cleanup
}
}
}
// store cleanup function for runCleanup()
cleanupFn = cleanup;
// handle cancellation signals
function handleSignal(): void {
log.info("» workflow cancelled, cleaning up...");
cleanup(true).finally(() => process.exit(1));
async function handleSignal(signal: "SIGINT" | "SIGTERM") {
await Promise.allSettled([...handlers].map((h) => Promise.try(h, signal)));
exitWithSignal(signal);
}
process.on("SIGINT", handleSignal);
process.on("SIGTERM", handleSignal);
}
/**
* Run cleanup explicitly. Called from entry.ts in finally block.
*/
export async function runCleanup(): Promise<void> {
try {
await cleanupFn?.(false);
} catch {
// ignore errors during cleanup
}
export function exitWithSignal(signal: "SIGINT" | "SIGTERM") {
process.exit(128 + os.constants.signals[signal]);
}
+25 -1
View File
@@ -1,6 +1,6 @@
import { LEAPING_INTO_ACTION_PREFIX } from "../mcp/comment.ts";
import { buildPullfrogFooter } from "./buildPullfrogFooter.ts";
import { log } from "./cli.ts";
import { buildErrorCommentBody } from "./exitHandler.ts";
import { createOctokit, parseRepoContext } from "./github.ts";
import { type ResolvedPromptInput, resolvePromptInput } from "./payload.ts";
import { getJobToken } from "./token.ts";
@@ -14,6 +14,30 @@ type JsonPromptInput = Extract<ResolvedPromptInput, object>; // not string
* */
const SHOULD_CHECK_REASON = true;
/**
* Build error comment body with error message and footer
*/
function buildErrorCommentBody(params: {
owner: string;
repo: string;
runId: string | undefined;
isCancellation: boolean;
}): string {
const workflowRunLink = params.runId
? `[workflow run logs](https://github.com/${params.owner}/${params.repo}/actions/runs/${params.runId})`
: "workflow run logs";
const errorMessage = params.isCancellation
? `This run was cancelled 🛑\n\nThe workflow was cancelled before completion. Please check the ${workflowRunLink} for details.`
: `This run croaked 😵\n\nThe workflow encountered an error before any progress could be reported. Please check the ${workflowRunLink} for details.`;
const footer = buildPullfrogFooter({
triggeredBy: true,
workflowRun: params.runId
? { owner: params.owner, repo: params.repo, runId: params.runId }
: undefined,
});
return `${errorMessage}${footer}`;
}
/**
* Validate that the progress comment is stuck on "Leaping into action"
* Fetches the comment by ID and checks if it starts with LEAPING_INTO_ACTION_PREFIX
+18 -26
View File
@@ -2,6 +2,7 @@ import { type ChildProcess, spawn as nodeSpawn } from "node:child_process";
import { performance } from "node:perf_hooks";
import { DEFAULT_ACTIVITY_CHECK_INTERVAL_MS, DEFAULT_ACTIVITY_TIMEOUT_MS } from "./activity.ts";
import { log } from "./cli.ts";
import { onExitSignal } from "./exitHandler.ts";
export type TrackChildOptions = {
child: ChildProcess;
@@ -18,6 +19,9 @@ let externalSignalHandler: SignalHandler | null = null;
// track a child process for cleanup on Ctrl+C
export function trackChild(options: TrackChildOptions): void {
// the signal handler cleans up all tracked children
// so we only have to install it once some child gets tracked
installSignalHandler();
activeChildren.set(options.child, options.killGroup ?? false);
}
@@ -32,8 +36,7 @@ export function setSignalHandler(handler: SignalHandler | null): void {
}
// kill all tracked children without exiting
export function killTrackedChildren(): number {
const count = activeChildren.size;
export function killTrackedChildren() {
for (const entry of activeChildren) {
const child = entry[0];
const killGroup = entry[1];
@@ -47,35 +50,24 @@ export function killTrackedChildren(): number {
}
child.kill("SIGKILL");
}
return count;
}
// cleanup handler for SIGINT/SIGTERM - kills all tracked children
function cleanupAndExit(signal: string): void {
const count = killTrackedChildren();
if (count > 0) {
log.info(`» received ${signal}, killing ${count} subprocess(es)...`);
}
// force exit after a short delay if process is stuck
setTimeout(() => process.exit(1), 500).unref();
process.exit(1);
}
function handleSignal(signal: NodeJS.Signals): void {
if (externalSignalHandler) {
externalSignalHandler(signal);
return;
}
cleanupAndExit(signal);
}
// install signal handlers once (call early in process lifecycle)
let handlersInstalled = false;
export function installSignalHandlers(): void {
function installSignalHandler(): void {
if (handlersInstalled) return;
handlersInstalled = true;
process.on("SIGINT", () => handleSignal("SIGINT"));
process.on("SIGTERM", () => handleSignal("SIGTERM"));
onExitSignal((signal) => {
if (externalSignalHandler) {
externalSignalHandler(signal);
return;
}
const count = activeChildren.size;
if (count > 0) {
log.info(`» received ${signal}, killing ${count} subprocess(es)...`);
}
killTrackedChildren();
});
}
export interface SpawnOptions {
@@ -106,7 +98,7 @@ export async function spawn(options: SpawnOptions): Promise<SpawnResult> {
const { cmd, args, env, input, timeout, cwd, stdio, onStdout, onStderr } = options;
const activityTimeoutMs = options.activityTimeout ?? DEFAULT_ACTIVITY_TIMEOUT_MS;
installSignalHandlers();
installSignalHandler();
const startTime = performance.now();
let stdoutBuffer = "";
+24 -6
View File
@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
import * as core from "@actions/core";
import type { PushPermission } from "../external.ts";
import { log } from "./cli.ts";
import { onExitSignal } from "./exitHandler.ts";
import { acquireNewToken } from "./github.ts";
import { isGitHubActions } from "./globals.ts";
@@ -131,18 +132,35 @@ export async function resolveTokens(params: ResolveTokensParams): Promise<TokenR
const revertGithubToken = setEnvironmentVariable("GITHUB_TOKEN", mcpToken);
mcpTokenValue = mcpToken;
return {
gitToken,
mcpToken,
async [Symbol.asyncDispose]() {
let disposingRef: PromiseWithResolvers<void> | undefined;
const dispose = async () => {
if (disposingRef) {
// this can happen if the signal arrives when disposing tokens
// we make sure to wait for the current dispose to complete
return disposingRef.promise;
}
disposingRef = Promise.withResolvers();
try {
mcpTokenValue = undefined;
revertGithubToken();
// revoke both tokens
await Promise.all([
revokeGitHubInstallationToken(gitToken),
revokeGitHubInstallationToken(mcpToken),
]);
},
} finally {
removeSignalHandler();
disposingRef.resolve();
disposingRef = undefined;
}
};
const removeSignalHandler = onExitSignal(dispose);
return {
gitToken,
mcpToken,
[Symbol.asyncDispose]: dispose,
};
}