Skip to content

Commit 830386d

Browse files
committed
core: add cancellation, timeout, and resource guards for image/document/webpage/folder/binary/table
1 parent 3b30d9a commit 830386d

17 files changed

Lines changed: 1242 additions & 134 deletions

File tree

apps/linsync-gui/qml/ImageComparePage.qml

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,8 @@ Controls.Pane {
160160
property var lastResult: null
161161
property real imageZoom: 1.0
162162
property bool splitViewActive: false
163+
property int imageRequestCounter: 0
164+
property string activeImageRequestId: ""
163165

164166
function bridgeGet(path, onLoad) {
165167
if (root.bridgeUrl === "") {
@@ -210,6 +212,9 @@ Controls.Pane {
210212
root.overlayUri = "";
211213
root.lastResult = null;
212214
root.statusText = "Comparing…";
215+
root.imageRequestCounter += 1;
216+
const reqId = "img-" + root.imageRequestCounter;
217+
root.activeImageRequestId = reqId;
213218

214219
const modeStr = modeCombo.currentText.toLowerCase();
215220
const tol = toleranceSpin.value;
@@ -218,14 +223,19 @@ Controls.Pane {
218223
// bridge expects; sending it unscaled made the threshold 10× too lenient.
219224
const deltaE = deltaESpin.value / 10;
220225
const frameMode = frameCombo.currentIndex === 1 ? "all" : "first";
221-
const url = "/compare/image" + "?left=" + encodeURIComponent(root.leftPath) + "&right=" + encodeURIComponent(root.rightPath) + "&mode=" + modeStr + "&tolerance=" + tol + "&delta_e=" + deltaE + "&frames=" + frameMode + "&overlay=true";
226+
const url = "/compare/image" + "?left=" + encodeURIComponent(root.leftPath) + "&right=" + encodeURIComponent(root.rightPath) + "&mode=" + modeStr + "&tolerance=" + tol + "&delta_e=" + deltaE + "&frames=" + frameMode + "&overlay=true" + "&request_id=" + encodeURIComponent(reqId);
222227

223228
root.bridgeGet(url, function (ok, data) {
224229
root.running = false;
230+
root.activeImageRequestId = "";
225231
if (!ok || !data) {
226232
root.statusText = "Compare failed — check file paths and format support.";
227233
return;
228234
}
235+
if (data.cancelled === true) {
236+
root.statusText = "Compare cancelled";
237+
return;
238+
}
229239
root.lastResult = data;
230240
if (data.session)
231241
root.sessionUpdated(data);
@@ -409,6 +419,18 @@ Controls.Pane {
409419
onClicked: root.runCompare()
410420
}
411421

422+
AppButton {
423+
Layout.preferredHeight: 30
424+
Layout.preferredWidth: 100
425+
text: qsTr("Stop")
426+
icon.name: "media-playback-stop"
427+
enabled: root.running && root.activeImageRequestId !== ""
428+
onClicked: {
429+
root.bridgeGet("/cancel?id=" + encodeURIComponent(root.activeImageRequestId))
430+
root.statusText = qsTr("Cancelling…")
431+
}
432+
}
433+
412434
Controls.BusyIndicator {
413435
running: root.running
414436
visible: root.running

apps/linsync-gui/qml/Main.qml

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,10 @@ Kirigami.ApplicationWindow {
5555
property int progressCurrent: 0
5656
property int progressTotal: 0
5757
property string progressMessage: ""
58+
// Defensive iteration cap for the progress timer so a missed cancel path
59+
// cannot poll forever. At 200 ms intervals 30000 iterations == ~100 min.
60+
property int progressPollCount: 0
61+
readonly property int progressPollMax: 30000
5862
// Compare-profile selector state (Phase 1). `profileEntries` mirrors
5963
// /profiles/list (built-ins first, then user profiles); `activeProfileId`
6064
// is the persisted active pointer; `profileError` surfaces a 400/404 inline.
@@ -136,6 +140,12 @@ Kirigami.ApplicationWindow {
136140
// appending a fetched window (vs. loading a fresh comparison).
137141
property bool suppressTextScrollReset: false
138142
property var folderEntries: []
143+
// Hard ceiling on how many folder rows the GUI will hold in memory for
144+
// windowed folders. Further lazy-load pages are dropped once the cap is
145+
// reached and the user is told to refine filters. Sort/filter/search changes
146+
// reset the model via queryFolderPage(0, false), so the cap only bounds
147+
// scroll-driven growth within one query.
148+
readonly property int folderEntriesMax: 50000
139149
property var visibleFolderEntries: []
140150
// Table compare grid data. tableCells holds the currently-loaded window of
141151
// rows; tableHeaders is populated when the input has a header row. Windowing
@@ -2220,6 +2230,10 @@ Kirigami.ApplicationWindow {
22202230
}
22212231
}
22222232
}
2233+
// The progressTimer already provides a defensive ceiling for stuck
2234+
// compares; any non-200 response in onreadystatechange above also clears
2235+
// the flags. Keep the request path simple because QML's XMLHttpRequest
2236+
// subset does not expose timeout/onerror callbacks.
22232237
request.open("GET", url)
22242238
request.send()
22252239
}
@@ -2434,6 +2448,10 @@ Kirigami.ApplicationWindow {
24342448
function queryFolderPage(offset, append) {
24352449
if (root.bridgeUrl === "" || root.compareMode !== "Folder" || root.folderWindowLoading)
24362450
return
2451+
if (append && root.folderEntries.length >= root.folderEntriesMax) {
2452+
root.statusText = qsTr("Folder view limited to %1 entries; refine filters to load more.").arg(root.folderEntriesMax)
2453+
return
2454+
}
24372455
root.folderWindowLoading = true
24382456
let url = root.bridgeUrl + "/folder/query?left=" + encodeURIComponent(root.leftPath)
24392457
+ "&right=" + encodeURIComponent(root.rightPath)
@@ -2464,6 +2482,10 @@ Kirigami.ApplicationWindow {
24642482
}
24652483
const payload = JSON.parse(request.responseText)
24662484
const entries = payload.entries || []
2485+
if (append && root.folderEntries.length + entries.length > root.folderEntriesMax) {
2486+
root.statusText = qsTr("Folder view limited to %1 entries; refine filters to load more.").arg(root.folderEntriesMax)
2487+
return
2488+
}
24672489
root.folderEntries = append ? root.folderEntries.concat(entries) : entries
24682490
if (payload.totalMatched !== undefined)
24692491
root.folderTotalEntries = Math.max(Number(payload.totalMatched), root.folderEntries.length)
@@ -2482,6 +2504,8 @@ Kirigami.ApplicationWindow {
24822504
return
24832505
if (root.folderEntries.length >= root.folderTotalEntries)
24842506
return
2507+
if (root.folderEntries.length >= root.folderEntriesMax)
2508+
return
24852509
if (view.contentHeight - (view.contentY + view.height) < view.height)
24862510
root.queryFolderPage(root.folderEntries.length, true)
24872511
}
@@ -6521,6 +6545,14 @@ Kirigami.ApplicationWindow {
65216545
progressTimer.stop()
65226546
return
65236547
}
6548+
root.progressPollCount += 1
6549+
if (root.progressPollCount > root.progressPollMax) {
6550+
root.comparing = false
6551+
root.activeRequestId = ""
6552+
root.statusText = qsTr("Compare monitoring timed out")
6553+
progressTimer.stop()
6554+
return
6555+
}
65246556
var req = new XMLHttpRequest()
65256557
req.onreadystatechange = function () {
65266558
if (req.readyState === XMLHttpRequest.DONE && req.status === 200) {
@@ -6549,13 +6581,15 @@ Kirigami.ApplicationWindow {
65496581
root.progressCurrent = 0
65506582
root.progressTotal = 0
65516583
root.progressMessage = ""
6584+
root.progressPollCount = 0
65526585
progressTimer.start()
65536586
} else {
65546587
progressTimer.stop()
65556588
root.progressPhase = "none"
65566589
root.progressCurrent = 0
65576590
root.progressTotal = 0
65586591
root.progressMessage = ""
6592+
root.progressPollCount = 0
65596593
}
65606594
}
65616595
}

apps/linsync-gui/src/bridge.rs

Lines changed: 29 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ pub(crate) fn start_bridge_server(
6262
let active = Arc::new(AtomicUsize::new(0));
6363
for stream in listener.incoming() {
6464
match stream {
65-
Ok(stream) => {
65+
Ok(mut stream) => {
6666
// Handle each connection on its own thread so a `/cancel`
6767
// request can be served while a `/compare` is still running
6868
// (the accept loop must not block on a single request).
@@ -71,7 +71,14 @@ pub(crate) fn start_bridge_server(
7171
concurrent = active.load(Ordering::Relaxed),
7272
"LinSync GUI bridge connection limit reached, rejecting"
7373
);
74-
// Drain and drop the stream so the client doesn't hang.
74+
// Return a 503 so the client sees a retry signal rather than
75+
// a silently dropped connection.
76+
let response = b"HTTP/1.1 503 Service Unavailable\r\nContent-Length: 0\r\nConnection: close\r\n\r\n";
77+
if let Err(err) = stream.write_all(response) {
78+
tracing::warn!(error = %err, "failed to write 503 to rejected bridge connection");
79+
} else if let Err(err) = stream.flush() {
80+
tracing::warn!(error = %err, "failed to flush 503 to rejected bridge connection");
81+
}
7582
drop(stream);
7683
continue;
7784
}
@@ -311,7 +318,7 @@ pub(crate) fn bridge_response_with_token(
311318
let req =
312319
register_cancellable_request(&params, state, "extracting", 3, "Extracting text");
313320
set_progress(
314-
&req.progress,
321+
&req.progress(),
315322
"extracting",
316323
1,
317324
3,
@@ -325,7 +332,6 @@ pub(crate) fn bridge_response_with_token(
325332
// If the user hit Stop during the (potentially slow) plugin
326333
// extraction, discard the result and report cancellation.
327334
if req.is_cancelled() {
328-
remove_cancellable_request(&req, state);
329335
return http_response(
330336
200,
331337
"OK",
@@ -334,7 +340,7 @@ pub(crate) fn bridge_response_with_token(
334340
);
335341
}
336342
set_progress(
337-
&req.progress,
343+
&req.progress(),
338344
"finalizing",
339345
2,
340346
3,
@@ -367,8 +373,7 @@ pub(crate) fn bridge_response_with_token(
367373
}
368374
}
369375
}
370-
set_progress(&req.progress, "done", 3, 3, String::new());
371-
remove_cancellable_request(&req, state);
376+
set_progress(&req.progress(), "done", 3, 3, String::new());
372377
http_response(200, "OK", "application/json", body.into_bytes())
373378
}
374379
"/profiles/list" => profiles_list_bridge_response(paths),
@@ -387,19 +392,20 @@ pub(crate) fn bridge_response_with_token(
387392
};
388393
let req =
389394
register_cancellable_request(&params, state, "comparing", 1, "Comparing images");
390-
let (mut body, result) =
391-
linsync::image_compare_bridge_response_with_profile(query, &profile.image);
395+
let (mut body, result) = linsync::image_compare_bridge_response_with_profile_and_cancel(
396+
query,
397+
&profile.image,
398+
req.cancel_checker(),
399+
);
392400
// If the user hit Stop during the compare, discard the result.
393401
if req.is_cancelled() {
394-
remove_cancellable_request(&req, state);
395402
return http_response(
396403
200,
397404
"OK",
398405
"application/json",
399406
br#"{"cancelled":true}"#.to_vec(),
400407
);
401408
}
402-
remove_cancellable_request(&req, state);
403409
let result_for_tab = result.clone();
404410
let overlay_path = serde_json::from_str::<serde_json::Value>(&body)
405411
.ok()
@@ -447,7 +453,7 @@ pub(crate) fn bridge_response_with_token(
447453
let req =
448454
register_cancellable_request(&params, state, "fetching", 3, "Fetching webpages");
449455
set_progress(
450-
&req.progress,
456+
&req.progress(),
451457
"fetching",
452458
1,
453459
3,
@@ -461,7 +467,6 @@ pub(crate) fn bridge_response_with_token(
461467
// If the user hit Stop during the (potentially slow) fetch/render,
462468
// discard the result and report cancellation.
463469
if req.is_cancelled() {
464-
remove_cancellable_request(&req, state);
465470
return http_response(
466471
200,
467472
"OK",
@@ -470,7 +475,7 @@ pub(crate) fn bridge_response_with_token(
470475
);
471476
}
472477
set_progress(
473-
&req.progress,
478+
&req.progress(),
474479
"finalizing",
475480
2,
476481
3,
@@ -492,8 +497,7 @@ pub(crate) fn bridge_response_with_token(
492497
state,
493498
);
494499
}
495-
set_progress(&req.progress, "done", 3, 3, String::new());
496-
remove_cancellable_request(&req, state);
500+
set_progress(&req.progress(), "done", 3, 3, String::new());
497501
http_response(200, "OK", "application/json", body.into_bytes())
498502
}
499503
"/compare/webpage/clear-cache" => {
@@ -1355,34 +1359,17 @@ pub(crate) fn compare_bridge_response(
13551359
// cancel flag so a concurrent `/cancel?id=X` can abort this compare. The
13561360
// flag is registered/removed under the state lock, but the long compare
13571361
// below runs WITHOUT holding the lock, so `/cancel` is never blocked by it.
1358-
let (request_id, progress) =
1359-
register_progress_request(&params, state, "starting", 0, "Starting compare");
1360-
let should_cancel: Box<dyn Fn() -> bool> = if let Some(id) = &request_id {
1361-
let flag = Arc::new(AtomicBool::new(false));
1362-
if let Ok(mut state) = state.lock() {
1363-
state.compare_cancels.insert(id.clone(), Arc::clone(&flag));
1364-
}
1365-
Box::new(move || flag.load(Ordering::Relaxed))
1366-
} else {
1367-
Box::new(|| false)
1368-
};
1362+
let req = register_cancellable_request(&params, state, "starting", 0, "Starting compare");
13691363

13701364
let maybe_tab = build_tab_for_paths_with_mode_cancellable_and_artifacts(
13711365
Path::new(left),
13721366
Path::new(right),
13731367
query_value(&params, "mode"),
13741368
&options,
1375-
&*should_cancel,
1376-
progress,
1369+
req.cancel_checker(),
1370+
req.progress(),
13771371
);
13781372

1379-
if let Some(id) = &request_id
1380-
&& let Ok(mut state) = state.lock()
1381-
{
1382-
state.compare_cancels.remove(id);
1383-
}
1384-
remove_progress_request(request_id.as_deref(), state);
1385-
13861373
let Some((tab, artifact_dirs)) = maybe_tab else {
13871374
// The compare was cancelled — leave the session state untouched.
13881375
return http_response(
@@ -3125,26 +3112,17 @@ pub(crate) fn sessions_save_bridge_response(
31253112
session_file.selected_view = compare_view_mode(&tab.mode);
31263113
persist_tab_snapshot(&mut session_file, &tab);
31273114
let store = RecentSessionStore::new(paths.recent_sessions_file(), recent_limit(paths));
3128-
let mut recent: RecentSessions = match store.load_or_default() {
3129-
Ok(value) => value,
3130-
Err(err) => {
3131-
return bridge_error(
3132-
500,
3133-
"Internal Server Error",
3134-
&format!("failed to load sessions: {err}"),
3135-
);
3115+
match store.add(session_file) {
3116+
Ok(_) => {
3117+
let body = serde_json::json!({"ok":true}).to_string();
3118+
http_response(200, "OK", "application/json", body.into_bytes())
31363119
}
3137-
};
3138-
recent.sessions.insert(0, session_file);
3139-
if let Err(err) = store.save(&recent) {
3140-
return bridge_error(
3120+
Err(err) => bridge_error(
31413121
500,
31423122
"Internal Server Error",
31433123
&format!("failed to save session: {err}"),
3144-
);
3124+
),
31453125
}
3146-
let body = serde_json::json!({"ok":true}).to_string();
3147-
http_response(200, "OK", "application/json", body.into_bytes())
31483126
}
31493127

31503128
pub(crate) fn filters_list_bridge_response(paths: &AppPaths) -> Vec<u8> {

0 commit comments

Comments
 (0)