Skip to content

Commit c0c9a4e

Browse files
authored
fix: mark push CANCELED when client disconnects mid-push (#217)
## Summary - Adds an `onDisconnect` callback to `HeartbeatSender` — fires once when a sideband write throws, indicating the client has gone away - `StoreAndForwardReceivePackFactory` wires the callback to `PushStore.cancel()` on the active push record, using `validationRecordId` (PENDING) if set, otherwise the initial `pushId` (RECEIVED) - No sweeper added — a TTL-based approach cannot distinguish abandoned RECEIVED records from PENDING records legitimately waiting in the approval queue closes #214 ## Test plan - [ ] Push to `/push/` path and Ctrl+C mid-way through the approval wait; confirm push record transitions to CANCELED - [ ] Normal push (no disconnect) still reaches FORWARDED correctly - [ ] Unit tests pass: `./gradlew :git-proxy-java-core:test`
2 parents 4e9cd82 + 79915fd commit c0c9a4e

4 files changed

Lines changed: 43 additions & 8 deletions

File tree

git-proxy-java-core/src/main/java/org/finos/gitproxy/git/HeartbeatSender.java

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,13 @@
1515
* The dot is harmless whitespace and does not affect validation output. If the interval is zero or negative the sender
1616
* is a no-op.
1717
*
18+
* <p>An optional {@code onDisconnect} callback is invoked once when a write fails, indicating the client has gone away.
19+
* The callback runs on the heartbeat thread and must be short and non-blocking.
20+
*
1821
* <p>Usage:
1922
*
2023
* <pre>{@code
21-
* try (HeartbeatSender hb = new HeartbeatSender(rp, Duration.ofSeconds(10))) {
24+
* try (HeartbeatSender hb = new HeartbeatSender(rp, Duration.ofSeconds(10), this::handleDisconnect)) {
2225
* hb.start();
2326
* // ... long-running hook chain ...
2427
* }
@@ -34,11 +37,17 @@ public class HeartbeatSender implements AutoCloseable {
3437

3538
private final ReceivePack rp;
3639
private final Duration interval;
40+
private final Runnable onDisconnect;
3741
private ScheduledExecutorService executor;
3842

3943
public HeartbeatSender(ReceivePack rp, Duration interval) {
44+
this(rp, interval, null);
45+
}
46+
47+
public HeartbeatSender(ReceivePack rp, Duration interval, Runnable onDisconnect) {
4048
this.rp = rp;
4149
this.interval = interval;
50+
this.onDisconnect = onDisconnect;
4251
}
4352

4453
/** Starts the heartbeat background thread. No-op if interval is zero or negative. */
@@ -61,10 +70,16 @@ private void sendDot() {
6170
rp.sendMessage(".");
6271
rp.getMessageOutputStream().flush();
6372
} catch (Exception e) {
64-
// Session may have ended; stop firing
6573
if (executor != null) {
6674
executor.shutdownNow();
6775
}
76+
if (onDisconnect != null) {
77+
try {
78+
onDisconnect.run();
79+
} catch (Exception ex) {
80+
log.warn("Disconnect callback threw", ex);
81+
}
82+
}
6883
}
6984
}
7085

git-proxy-java-core/src/main/java/org/finos/gitproxy/git/StoreAndForwardReceivePackFactory.java

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -256,7 +256,25 @@ public ReceivePack create(HttpServletRequest req, Repository db)
256256
preHooks = validationHooks.toArray(PreReceiveHook[]::new);
257257
}
258258

259-
rp.setPreReceiveHook(chainPreReceiveHooks(heartbeatInterval, validationContext, failFast, preHooks));
259+
Runnable disconnectCallback = null;
260+
if (persistenceHook != null) {
261+
final PushContext capturedContext = pushContext;
262+
disconnectCallback = () -> {
263+
String recordId = capturedContext.getValidationRecordId();
264+
if (recordId == null) recordId = capturedContext.getPushId();
265+
if (recordId != null) {
266+
try {
267+
pushStore.cancel(recordId, null);
268+
log.info("Push {} marked CANCELED: client disconnected mid-push", recordId);
269+
} catch (Exception e) {
270+
log.warn("Failed to mark push {} as CANCELED after client disconnect", recordId, e);
271+
}
272+
}
273+
};
274+
}
275+
276+
rp.setPreReceiveHook(
277+
chainPreReceiveHooks(heartbeatInterval, validationContext, failFast, disconnectCallback, preHooks));
260278

261279
// Post-receive: forward to upstream, then record final status
262280
var forwardingHook = new ForwardingPostReceiveHook(provider, creds, pushContext, connectTimeoutSeconds);
@@ -275,9 +293,10 @@ private static PreReceiveHook chainPreReceiveHooks(
275293
Duration heartbeatInterval,
276294
ValidationContext validationContext,
277295
boolean failFast,
296+
Runnable disconnectCallback,
278297
PreReceiveHook... hooks) {
279298
return (ReceivePack rp, Collection<ReceiveCommand> commands) -> {
280-
try (HeartbeatSender heartbeat = new HeartbeatSender(rp, heartbeatInterval)) {
299+
try (HeartbeatSender heartbeat = new HeartbeatSender(rp, heartbeatInterval, disconnectCallback)) {
281300
heartbeat.start();
282301
boolean skipValidationHooks = false;
283302
for (PreReceiveHook hook : hooks) {

git-proxy-java-dashboard/src/main/java/org/finos/gitproxy/dashboard/controller/PushController.java

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,7 @@ public record ApproveBody(
195195
String reviewerEmail,
196196
String reason,
197197
Map<String, String> attestations,
198-
boolean adminOverride) {}
198+
Boolean adminOverride) {}
199199

200200
/**
201201
* Approve a push. Body: { "reviewerUsername": "...", "reviewerEmail": "...", "reason": "...", "attestations": {
@@ -211,7 +211,8 @@ public ResponseEntity<?> approve(@PathVariable String id, @RequestBody ApproveBo
211211
return ResponseEntity.badRequest()
212212
.body(Map.of("error", "Push is not in PENDING status: " + record.getStatus()));
213213
}
214-
ResponseEntity<?> identityError = checkReviewerIdentity(record, body.adminOverride());
214+
boolean adminOverride = Boolean.TRUE.equals(body.adminOverride());
215+
ResponseEntity<?> identityError = checkReviewerIdentity(record, adminOverride);
215216
if (identityError != null) return identityError;
216217

217218
// Validate required attestation questions are answered
@@ -225,7 +226,7 @@ public ResponseEntity<?> approve(@PathVariable String id, @RequestBody ApproveBo
225226
.reviewerUsername(resolveReviewerFromApproveBody(body, auth))
226227
.reviewerEmail(body.reviewerEmail())
227228
.reason(body.reason())
228-
.selfApproval(isSelfApproval(record, auth, body.adminOverride()))
229+
.selfApproval(isSelfApproval(record, auth, adminOverride))
229230
.answers(body.attestations())
230231
.build();
231232
var updated = pushStore.approve(id, attestation);

test/proxy-pass.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ APPROVE_RESPONSE=$(curl -s -o /dev/null -w "%{http_code}" \
4646
-X POST "http://localhost:8080/api/push/${PUSH_ID}/authorise" \
4747
-H "Content-Type: application/json" \
4848
-H "X-Api-Key: ${GITPROXY_API_KEY}" \
49-
-d '{"user":"test-script","comment":"auto-approved by proxy-pass.sh","attestations":{"reviewed-content":"true","policy-compliance":"true"}}')
49+
-d '{"reviewerUsername":"test-script","reason":"auto-approved by proxy-pass.sh"}')
5050
if [ "${APPROVE_RESPONSE}" != "200" ]; then
5151
echo "ERROR: Approval API returned HTTP ${APPROVE_RESPONSE}"
5252
exit 1

0 commit comments

Comments
 (0)