fix: unregister gosmee webhooks on forced shutdown (#47)

This commit is contained in:
insleker 2026-04-11 18:08:45 +08:00
parent 0e9bd11d5c
commit 22f2ad9c9d
8 changed files with 537 additions and 20 deletions

View File

@ -20,6 +20,7 @@ class GiteaGosmeeIssueEventSource implements IssueEventSource {
}) : _logger = logger;
static const Duration _webhookCoalescingWindow = Duration(milliseconds: 500);
static const Duration _processShutdownTimeout = Duration(seconds: 2);
final IssueTrackerClient issueTrackerClient;
final String gosmeeCommand;
@ -206,12 +207,7 @@ class GiteaGosmeeIssueEventSource implements IssueEventSource {
final processes = _gosmeeProcesses.values.toList(growable: false);
_gosmeeProcesses.clear();
for (final process in processes) {
process.kill();
}
for (final process in processes) {
await process.exitCode.catchError((_) => 0);
}
await _terminateProcesses(processes);
final webhookIds = Map<String, int>.from(_webhookIdsByProject);
_webhookIdsByProject.clear();
@ -451,6 +447,37 @@ class GiteaGosmeeIssueEventSource implements IssueEventSource {
_logger.error(message);
}
Future<void> _terminateProcesses(
List<StartedExternalCommand> processes,
) async {
for (final process in processes) {
process.kill();
}
for (final process in processes) {
await _awaitProcessExit(process);
}
}
Future<void> _awaitProcessExit(StartedExternalCommand process) async {
try {
await process.exitCode.timeout(_processShutdownTimeout);
return;
} on TimeoutException {
process.kill(ProcessSignal.sigkill);
} catch (_) {
return;
}
try {
await process.exitCode.timeout(_processShutdownTimeout);
} on TimeoutException {
_logError(
'gitea/gosmee process did not exit after sigkill '
'timeout_ms=${_processShutdownTimeout.inMilliseconds}',
);
} catch (_) {}
}
String _summarizeText(String value) {
final singleLine = value.trim().replaceAll(RegExp(r'\s+'), ' ');
if (singleLine.length <= 240) {

View File

@ -20,6 +20,7 @@ class GitHubGosmeeIssueEventSource implements IssueEventSource {
}) : _logger = logger;
static const Duration _webhookCoalescingWindow = Duration(milliseconds: 500);
static const Duration _processShutdownTimeout = Duration(seconds: 2);
final IssueTrackerClient issueTrackerClient;
final String gosmeeCommand;
@ -206,12 +207,7 @@ class GitHubGosmeeIssueEventSource implements IssueEventSource {
final processes = _gosmeeProcesses.values.toList(growable: false);
_gosmeeProcesses.clear();
for (final process in processes) {
process.kill();
}
for (final process in processes) {
await process.exitCode.catchError((_) => 0);
}
await _terminateProcesses(processes);
final webhookIds = Map<String, int>.from(_webhookIdsByProject);
_webhookIdsByProject.clear();
@ -447,6 +443,37 @@ class GitHubGosmeeIssueEventSource implements IssueEventSource {
void _logError(String message) => _logger.error(message);
Future<void> _terminateProcesses(
List<StartedExternalCommand> processes,
) async {
for (final process in processes) {
process.kill();
}
for (final process in processes) {
await _awaitProcessExit(process);
}
}
Future<void> _awaitProcessExit(StartedExternalCommand process) async {
try {
await process.exitCode.timeout(_processShutdownTimeout);
return;
} on TimeoutException {
process.kill(ProcessSignal.sigkill);
} catch (_) {
return;
}
try {
await process.exitCode.timeout(_processShutdownTimeout);
} on TimeoutException {
_logError(
'github/gosmee process did not exit after sigkill '
'timeout_ms=${_processShutdownTimeout.inMilliseconds}',
);
} catch (_) {}
}
String _summarizeText(String value) {
final singleLine = value.trim().replaceAll(RegExp(r'\s+'), ' ');
if (singleLine.length <= 240) {

View File

@ -18,6 +18,7 @@ class GitLabGosmeeIssueEventSource implements IssueEventSource {
}) : _logger = logger;
static const Duration _webhookCoalescingWindow = Duration(milliseconds: 500);
static const Duration _processShutdownTimeout = Duration(seconds: 2);
final IssueTrackerClient issueTrackerClient;
final String gosmeeCommand;
@ -204,12 +205,7 @@ class GitLabGosmeeIssueEventSource implements IssueEventSource {
final processes = _gosmeeProcesses.values.toList(growable: false);
_gosmeeProcesses.clear();
for (final process in processes) {
process.kill();
}
for (final process in processes) {
await process.exitCode.catchError((_) => 0);
}
await _terminateProcesses(processes);
final webhookIds = Map<String, int>.from(_webhookIdsByProject);
_webhookIdsByProject.clear();
@ -485,6 +481,37 @@ class GitLabGosmeeIssueEventSource implements IssueEventSource {
void _logError(String message) => _logger.error(message);
Future<void> _terminateProcesses(
List<StartedExternalCommand> processes,
) async {
for (final process in processes) {
process.kill();
}
for (final process in processes) {
await _awaitProcessExit(process);
}
}
Future<void> _awaitProcessExit(StartedExternalCommand process) async {
try {
await process.exitCode.timeout(_processShutdownTimeout);
return;
} on TimeoutException {
process.kill(ProcessSignal.sigkill);
} catch (_) {
return;
}
try {
await process.exitCode.timeout(_processShutdownTimeout);
} on TimeoutException {
_logError(
'gitlab/gosmee process did not exit after sigkill '
'timeout_ms=${_processShutdownTimeout.inMilliseconds}',
);
} catch (_) {}
}
String _summarizeText(String value) {
final singleLine = value.trim().replaceAll(RegExp(r'\s+'), ' ');
if (singleLine.length <= 240) {

View File

@ -217,6 +217,141 @@ projects:
isTrue,
);
});
/// ```gherkin
/// Scenario: Delete webhook even when gosmee ignores SIGTERM during shutdown
/// Given a temporary project checkout that can be used as the local repo path
/// And a gosmee command that forwards one GitHub webhook event but ignores SIGTERM
/// And a gh script that can manage webhooks and post issue comments
/// And a responder that emits a planning reply
/// And an orchestrator-style config that uses gh/gosmee
/// When the long-running app starts and then closes
/// Then the app still deletes the GitHub webhook for the project
/// ```
test(
'Delete webhook even when gosmee ignores SIGTERM during shutdown',
() async {
// Given a temporary project checkout that can be used as the local repo path.
final sandbox = await Directory.systemTemp.createTemp(
'cws-gh-gosmee-shutdown-',
);
final repoDir = await Directory(p.join(sandbox.path, 'repo')).create();
await initGitRepo(repoDir);
// And a gosmee command that forwards one GitHub webhook event but ignores SIGTERM.
final gosmeeScript = File(p.join(sandbox.path, 'gosmee'));
final issue = {
'number': 21,
'title': 'Shutdown should still unregister webhook',
'body': 'Please react before shutdown.',
'state': 'open',
'html_url': 'https://example.test/issues/21',
'updated_at': '2026-04-08T07:00:00Z',
'user': {'login': 'reporter'},
'labels': const <Map<String, Object?>>[],
};
final comments = [
{
'id': 151,
'body': '@helper verify webhook cleanup on shutdown',
'html_url': 'https://example.test/issues/21#issuecomment-151',
'created_at': '2026-04-08T07:00:00Z',
'updated_at': '2026-04-08T07:00:00Z',
'user': {'login': 'reporter'},
},
];
registerFakeGosmeeCommand(
command: gosmeeScript.path,
publicUrl: 'https://gosmee.example.test/github-shutdown',
payload: {
'action': 'created',
'issue': issue,
'comment': comments.last,
'repository': {'full_name': 'owner/sample'},
},
webhookHeader: 'X-GitHub-Event',
exitOnSigterm: false,
);
// And a gh script that can manage webhooks and post issue comments.
final ghScript = File(p.join(sandbox.path, 'gh'));
final ghLog = File(p.join(sandbox.path, 'gh-log.jsonl'));
final postedBody = File(p.join(sandbox.path, 'posted-body.md'));
await writeGitHubGosmeeGhScript(
ghScript: ghScript,
repo: 'owner/sample',
webhookId: 81,
issueNumber: 21,
comments: comments,
ghLog: ghLog,
postedBodyFile: postedBody,
viewerLogin: 'octocat',
);
// And a responder that emits a planning reply.
final responderScript = File(p.join(sandbox.path, 'responder.sh'));
await writeResponderScript(responderScript, {
'decision': 'reply',
'mode': 'planning',
'markdown':
'Plan:\n- ensure webhook cleanup when shutdown races forwarding',
'summary': 'posted shutdown cleanup advice',
});
// And an orchestrator-style config that uses gh/gosmee.
final configFile = File(
p.join(sandbox.path, 'agent-orchestrator.yaml'),
);
await configFile.writeAsString('''
defaults:
issueAssistant:
enabled: true
mentionTriggers: ["@helper"]
responders:
- id: primary
command: ${responderScript.path}
timeout: 1m
projects:
sample:
issueTracker:
provider: github
eventSource:
pollInterval: 5m
type: gh/gosmee
repo: owner/sample
path: ${repoDir.path}
''');
final app = await IssueAssistantApp.open(
config: await AppConfig.load(configFile.path),
databasePath: p.join(sandbox.path, 'state.sqlite3'),
ghCommand: ghScript.path,
gosmeeCommand: gosmeeScript.path,
responderRunner: fakeResponderRunner(),
commandExecutor: fakeExternalCommandExecutor(),
);
// When the long-running app starts and then closes.
final runFuture = app.run();
for (var attempt = 0; attempt < 200; attempt += 1) {
if (await postedBody.exists()) {
break;
}
await Future<void>.delayed(const Duration(milliseconds: 100));
}
await app.close();
await runFuture;
// Then the app still deletes the GitHub webhook for the project.
final ghLogLines = await ghLog.readAsLines();
expect(
ghLogLines.any(
(line) =>
line.contains('api -X DELETE repos/owner/sample/hooks/81'),
),
isTrue,
);
},
);
});
}

View File

@ -236,6 +236,156 @@ projects:
isTrue,
);
});
/// ```gherkin
/// Scenario: Delete webhook even when gosmee ignores SIGTERM during shutdown
/// Given a temporary project checkout that can be used as the local repo path
/// And a gosmee command that forwards one GitLab webhook event but ignores SIGTERM
/// And a glab script that can manage webhooks and post issue comments
/// And a responder that emits a planning reply
/// And an orchestrator-style config that uses glab/gosmee
/// When the long-running app starts and then closes
/// Then the app still deletes the GitLab webhook for the project
/// ```
test(
'Delete webhook even when gosmee ignores SIGTERM during shutdown',
() async {
// Given a temporary project checkout that can be used as the local repo path.
final sandbox = await Directory.systemTemp.createTemp(
'cws-glab-gosmee-shutdown-',
);
final repoDir = await Directory(p.join(sandbox.path, 'repo')).create();
await initGitRepo(repoDir);
// And a gosmee command that forwards one GitLab webhook event but ignores SIGTERM.
final gosmeeScript = File(p.join(sandbox.path, 'gosmee'));
final issue = <String, Object?>{
'iid': 21,
'title': 'Shutdown should still unregister webhook',
'description': 'Please react before shutdown.',
'state': 'opened',
'web_url':
'https://gitlab.example.test/group/subgroup/sample/-/issues/21',
'updated_at': '2026-04-08T07:00:00Z',
'author': {'username': 'reporter'},
'labels': const <String>[],
};
final comments = <Map<String, Object?>>[
{
'id': 151,
'body': '@helper verify webhook cleanup on shutdown',
'created_at': '2026-04-08T07:00:00Z',
'updated_at': '2026-04-08T07:00:00Z',
'author': {'username': 'reporter'},
'noteable_url':
'https://gitlab.example.test/group/subgroup/sample/-/issues/21',
},
];
registerFakeGosmeeCommand(
command: gosmeeScript.path,
publicUrl: 'https://gosmee.example.test/gitlab-shutdown',
payload: {
'object_kind': 'note',
'project': {'path_with_namespace': 'group/subgroup/sample'},
'issue': issue,
'object_attributes': {
'action': 'create',
'note': comments.last['body'],
'noteable_type': 'Issue',
'url': comments.last['noteable_url'],
},
'user': {'username': 'reporter'},
},
webhookHeader: 'X-Gitlab-Event',
exitOnSigterm: false,
);
// And a glab script that can manage webhooks and post issue comments.
final glabScript = File(p.join(sandbox.path, 'glab'));
final glabLog = File(p.join(sandbox.path, 'glab-log.jsonl'));
final postedBody = File(p.join(sandbox.path, 'posted-body.md'));
await writeFakeGlabScript(
glabScript: glabScript,
issueListResponsesByRepo: {
'group/subgroup/sample': const <Map<String, Object?>>[],
},
commentResponsesByRepo: {
'group/subgroup/sample': {21: comments},
},
postCommentIssueNumbersByRepo: {
'group/subgroup/sample': {21},
},
glabLog: glabLog,
postedBodyFile: postedBody,
viewerLogin: 'glab-user',
webhookIdsByRepo: {'group/subgroup/sample': 81},
);
// And a responder that emits a planning reply.
final responderScript = File(p.join(sandbox.path, 'responder.sh'));
await writeResponderScript(responderScript, {
'decision': 'reply',
'mode': 'planning',
'markdown':
'Plan:\n- ensure webhook cleanup when shutdown races forwarding',
'summary': 'posted shutdown cleanup advice',
});
// And an orchestrator-style config that uses glab/gosmee.
final configFile = File(
p.join(sandbox.path, 'agent-orchestrator.yaml'),
);
await configFile.writeAsString('''
defaults:
issueAssistant:
enabled: true
mentionTriggers: ["@helper"]
responders:
- id: primary
command: ${responderScript.path}
timeout: 1m
projects:
sample:
issueTracker:
provider: gitlab
eventSource:
pollInterval: 5m
type: glab/gosmee
repo: group/subgroup/sample
path: ${repoDir.path}
''');
final app = await IssueAssistantApp.open(
config: await AppConfig.load(configFile.path),
databasePath: p.join(sandbox.path, 'state.sqlite3'),
glabCommand: glabScript.path,
gosmeeCommand: gosmeeScript.path,
responderRunner: fakeResponderRunner(),
commandExecutor: fakeExternalCommandExecutor(),
);
// When the long-running app starts and then closes.
final runFuture = app.run();
for (var attempt = 0; attempt < 200; attempt += 1) {
if (await postedBody.exists()) {
break;
}
await Future<void>.delayed(const Duration(milliseconds: 100));
}
await app.close();
await runFuture;
// Then the app still deletes the GitLab webhook for the project.
final glabLogLines = await glabLog.readAsLines();
expect(
glabLogLines.any(
(line) => line.contains(
'api -X DELETE projects/group%2Fsubgroup%2Fsample/hooks/81',
),
),
isTrue,
);
},
);
});
}

View File

@ -365,6 +365,138 @@ CWS_GITEA_TOKEN=test-token
isTrue,
);
});
/// ```gherkin
/// Scenario: Delete webhook even when gosmee ignores SIGTERM during shutdown
/// Given a temporary project checkout that can be used as the local repo path
/// And a gosmee command that forwards one Gitea webhook event but ignores SIGTERM
/// And a Gitea API fixture that can manage webhooks and post issue comments
/// And a responder that emits a planning reply
/// And an orchestrator-style config that uses tea/gosmee
/// When the long-running app starts and then closes
/// Then the app still deletes the Gitea webhook for the project
/// ```
test('Delete webhook even when gosmee ignores SIGTERM during shutdown', () async {
// Given a temporary project checkout that can be used as the local repo path.
final sandbox = await Directory.systemTemp.createTemp(
'cws-gosmee-shutdown-',
);
final repoDir = await Directory(p.join(sandbox.path, 'repo')).create();
await initGitRepo(repoDir);
// And a gosmee command that forwards one Gitea webhook event but ignores SIGTERM.
final gosmeeScript = File(p.join(sandbox.path, 'gosmee'));
final issue = {
'index': 21,
'title': 'Shutdown should still unregister webhook',
'body': 'Please react before shutdown.',
'state': 'open',
'url': 'https://gitea.example.test/owner/sample/issues/21',
'updated_at': '2026-04-08T07:00:00Z',
'poster': {'login': 'reporter'},
'labels': const <Map<String, Object?>>[],
};
final comments = [
{
'id': 151,
'body': '@helper verify webhook cleanup on shutdown',
'url':
'https://gitea.example.test/owner/sample/issues/21#issuecomment-151',
'created_at': '2026-04-08T07:00:00Z',
'updated_at': '2026-04-08T07:00:00Z',
'poster': {'login': 'reporter'},
},
];
registerFakeGosmeeCommand(
command: gosmeeScript.path,
publicUrl: 'https://gosmee.example.test/shutdown',
payload: {
'action': 'created',
'issue': issue,
'comment': comments.last,
'repository': {'full_name': 'owner/sample'},
},
exitOnSigterm: false,
);
// And a Gitea API fixture that can manage webhooks and post issue comments.
final giteaServer = await FakeGiteaApiServer.start(
issueListResponsesByRepo: {
'owner/sample': const <Map<String, Object?>>[],
},
commentResponsesByRepo: {
'owner/sample': {21: comments},
},
postCommentResponsesByRepo: {
'owner/sample': {
21: {
'id': 803,
'body': 'posted',
'html_url': 'https://gitea.example.test/comment/803',
},
},
},
viewerLogin: 'tea-octocat',
);
addTearDown(giteaServer.close);
// And a responder that emits a planning reply.
final responderScript = File(p.join(sandbox.path, 'responder.sh'));
await writeResponderScript(responderScript, {
'decision': 'reply',
'mode': 'planning',
'markdown':
'Plan:\n- ensure webhook cleanup when shutdown races forwarding',
'summary': 'posted shutdown cleanup advice',
});
// And an orchestrator-style config that uses tea/gosmee.
final configFile = File(p.join(sandbox.path, 'agent-orchestrator.yaml'));
await configFile.writeAsString('''
defaults:
issueAssistant:
enabled: true
mentionTriggers: ["@helper"]
responders:
- id: primary
command: ${responderScript.path}
timeout: 1m
projects:
sample:
issueTracker:
provider: gitea
eventSource:
pollInterval: 5m
type: tea/gosmee
repo: owner/sample
path: ${repoDir.path}
''');
await File(p.join(sandbox.path, '.env')).writeAsString('''
CWS_GITEA_HOST=${giteaServer.baseUrl}
CWS_GITEA_TOKEN=test-token
''');
final app = await IssueAssistantApp.open(
config: await AppConfig.load(configFile.path),
databasePath: p.join(sandbox.path, 'state.sqlite3'),
gosmeeCommand: gosmeeScript.path,
responderRunner: fakeResponderRunner(),
commandExecutor: fakeExternalCommandExecutor(),
);
// When the long-running app starts and then closes.
final runFuture = app.run();
for (var attempt = 0; attempt < 200; attempt += 1) {
if (giteaServer.postedBodies.isNotEmpty) {
break;
}
await Future<void>.delayed(const Duration(milliseconds: 100));
}
await app.close();
await runFuture;
// Then the app still deletes the Gitea webhook for the project.
expect(giteaServer.deletedWebhookIds, contains(81));
});
});
}

View File

@ -24,6 +24,7 @@ void registerFakeGosmeeCommand({
Map<String, Object?>? payload,
File? gosmeeLog,
String webhookHeader = 'X-Gitea-Event',
bool exitOnSigterm = true,
}) {
fakeCommandHandlers[command] = (arguments, {timeout}) async {
if (gosmeeLog != null) {
@ -44,7 +45,7 @@ void registerFakeGosmeeCommand({
if (gosmeeLog != null) {
await _appendLine(gosmeeLog, '${arguments.join(' ')}\n');
}
final process = FakeStartedExternalCommand();
final process = FakeStartedExternalCommand(exitOnSigterm: exitOnSigterm);
if (arguments.length >= 3 &&
arguments[0] == 'client' &&
arguments[1] == publicUrl) {

View File

@ -61,6 +61,15 @@ ExternalCommandExecutor fakeExternalCommandExecutor() {
}
class FakeStartedExternalCommand implements StartedExternalCommand {
FakeStartedExternalCommand({
this.exitOnSigterm = true,
this.exitOnSigkill = true,
this.sigkillExitCode = 137,
});
final bool exitOnSigterm;
final bool exitOnSigkill;
final int sigkillExitCode;
final StreamController<List<int>> _stdoutController = StreamController();
final StreamController<List<int>> _stderrController = StreamController();
final Completer<int> _exitCodeCompleter = Completer<int>();
@ -96,7 +105,16 @@ class FakeStartedExternalCommand implements StartedExternalCommand {
@override
bool kill([ProcessSignal signal = ProcessSignal.sigterm]) {
complete();
if (signal == ProcessSignal.sigkill) {
if (exitOnSigkill) {
complete(sigkillExitCode);
}
return true;
}
if (signal == ProcessSignal.sigterm && exitOnSigterm) {
complete();
return true;
}
return true;
}
}