From 775c2873e4931ac2ced17487ed4a1c1512afa7b6 Mon Sep 17 00:00:00 2001 From: Musa Mahmood Date: Fri, 9 Oct 2026 23:37:35 +0100 Subject: [PATCH] [0.0.10] Shutdown: say what it is waiting on, stop OCR mid-batch, no race with the frame loop - The exit overlay says which step of shutdown is running and what it waits on ("Stopping the OCR scan: finishing the text lines it is reading.", the import, the viewer, the models), and how long that step has taken once it passes 2 s. It said "Saving..." for all of it. - OCR stops mid-batch: ocr_process takes a cancel flag (Ocr_Engine.cancel), checked by its workers before each piece of work; a scan sets it to its own cancel. A cut-short batch stores nothing and counts no failures. Closing during a scan took minutes when a batch held large images; now well under a second. - Once shutdown starts, the main thread stops running the app's frames (the viewer, search, import, OCR, Stats) and draws only the overlay: background_frame read the search thread group while the shutdown thread was tearing it down, an access violation on exit. --- check_targets.jai | 2 +- first.jai | 2 +- modules/OCR/module.jai | 24 +++++++++++++++ src/app/app_ui.jai | 58 ++++++++++++++++++++++++++++++++++- src/app/exit_shutdown.jai | 9 +++--- src/app/ocr_ui.jai | 9 ++++++ src/ui/imgui_exit_overlay.jai | 5 +-- 7 files changed, 100 insertions(+), 9 deletions(-) diff --git a/check_targets.jai b/check_targets.jai index ec0827e..43a6dee 100644 --- a/check_targets.jai +++ b/check_targets.jai @@ -29,7 +29,7 @@ APP_NAME :: "mcap"; APP_FOLDER_NAME :: "mcap"; GIT_BRANCH :: "check"; GIT_HASH :: "-"; -VERSION :: "0.0.9"; +VERSION :: "0.0.10"; BUILD_DATE :: "-"; DEBUG :: true; COMPILE_TESTS :: true; diff --git a/first.jai b/first.jai index 36ff17c..181a02f 100644 --- a/first.jai +++ b/first.jai @@ -8,7 +8,7 @@ APP_NAME :: "mcap"; APP_FOLDER_NAME :: "mcap"; APP_BUNDLE_IDENTIFIER :: "com.example.mcap"; -VERSION :: "0.0.9"; +VERSION :: "0.0.10"; // Where everything goes: bin/, or bin-headless/ for builds made with no_crash_dialog, so automated // builds never write over the ones you run by hand. build_directory := "bin"; diff --git a/modules/OCR/module.jai b/modules/OCR/module.jai index 5ce4fbb..40668de 100644 --- a/modules/OCR/module.jai +++ b/modules/OCR/module.jai @@ -47,6 +47,16 @@ Ocr_Engine :: struct { rec_batch: s32; // text lines per recognizer run: the model's rec_batch_gpu or rec_batch_cpu options: Ocr_Options; stats: Ocr_Stats; // since creation, or the last ocr_reset_stats + + // When set, and true, ocr_process stops starting work: the batch of lines or image each worker is + // on is finished, and then every image fails as "cancelled" (none is part read). Set by the caller. + cancel: *bool; +} + +OCR_CANCELLED :: "cancelled"; + +ocr_cancelled :: (engine: *Ocr_Engine) -> bool { + return engine.cancel && engine.cancel.*; } // Time spent in each stage, summed over the worker threads (so it can exceed the elapsed time). @@ -182,6 +192,7 @@ ocr_process :: (engine: *Ocr_Engine, images: [] Ocr_Image, allocator := context. atomic_add(*engine.stats.detect_us, to_microseconds(current_time_monotonic() - start)); if work.error work.error = copy_string(work.error,, context.default_allocator); }, per_image.data); + if ocr_cancelled(engine) return ocr_all_cancelled(results, allocator); // Recognition: every line of every image, batched by width so little of each batch is padding. jobs: [..] Line_Job; @@ -251,6 +262,10 @@ ocr_process :: (engine: *Ocr_Engine, images: [] Ocr_Image, allocator := context. atomic_add(*engine.stats.recognize_us, to_microseconds(current_time_monotonic() - start)); if !ok { b.failed = true; b.error = copy_string(error,, context.default_allocator); } // the last error wins; any is enough to say why }, *batches); + if ocr_cancelled(engine) { + free(batches.error,, context.default_allocator); + return ocr_all_cancelled(results, allocator); + } if classify.failed && !batches.failed { batches.failed = true; batches.error = copy_string(classify.error,, context.default_allocator); @@ -304,6 +319,14 @@ ocr_process :: (engine: *Ocr_Engine, images: [] Ocr_Image, allocator := context. #scope_module +ocr_all_cancelled :: (results: [] Ocr_Result, allocator: Allocator) -> [] Ocr_Result { + for * results { + it.failed = true; + it.error = copy_string(OCR_CANCELLED,, allocator); + } + return results; +} + Image_Work :: struct { image: *Ocr_Image; boxes: [..] Detected_Box; @@ -326,6 +349,7 @@ run_on_workers :: (engine: *Ocr_Engine, count: s64, proc: Work_Proc, data: *void context.allocator = context.default_allocator; shared := cast(*Shared) thread.data; while true { + if ocr_cancelled(shared.engine) break; // what is left fails as cancelled (ocr_process) index := atomic_add(*shared.next, 1); if index >= shared.count break; shared.proc(shared.engine, index, shared.data); diff --git a/src/app/app_ui.jai b/src/app/app_ui.jai index ed3a5ce..9372821 100644 --- a/src/app/app_ui.jai +++ b/src/app/app_ui.jai @@ -17,17 +17,73 @@ app_ui_frame_start :: () { } // Once, on the shutdown thread after the window is asked to close (exit_shutdown.jai): save and -// stop background work here. Must not touch ImGui, since the main thread is still drawing frames. +// stop background work here. Must not touch ImGui, since the main thread is still drawing frames +// (the overlay; app_main_window_ui stops running the app's frames once shutdown starts). app_ui_shutdown :: () { + app_shutdown_step(.IMPORT); import_shutdown(); // before the viewer: it hands the viewer what it writes + app_shutdown_step(.VIEWER); viewer_shutdown(); + app_shutdown_step(.OCR); ocr_ui_shutdown(); + app_shutdown_step(.LABELS); labels_ui_shutdown(); // after the OCR thread and the background workers have stopped + app_shutdown_step(.STATS); stats_ui_shutdown(); } +// Which step of app_ui_shutdown is running, and since when, for the overlay a slow shutdown shows. +App_Shutdown_Step :: enum s32 { NONE; IMPORT; VIEWER; OCR; LABELS; STATS; } +app_shutdown: struct { + step: App_Shutdown_Step; + step_started_ns: s64; // monotonic +} + +app_shutdown_step :: (step: App_Shutdown_Step) { + atomic_write(*app_shutdown.step_started_ns, to_nanoseconds(current_time_monotonic())); + atomic_write(*app_shutdown.step, step); +} + +// Main thread, while app_ui_shutdown runs: what it is waiting on, as a line or two for the overlay +// (temp); "" before it has started a step. +app_ui_shutdown_status :: () -> string { + step := atomic_read(*app_shutdown.step); + text: string; + if step == { + case .NONE; return ""; + case .IMPORT; + if importer.phase == .INDEXING text = "Stopping the import: it is indexing the library, and stops after the files it is on."; + else if importer.running text = "Stopping the import: finishing the files it is on (a large video takes a while to copy)."; + else text = "Stopping the import."; + case .VIEWER; + text = "Stopping the thumbnail, catalog and search workers"; + if viewer.open_started text = tprint("%, and writing out % to play", text, viewer.open_name); + text = tprint("%.", text); + case .OCR; + text = "Closing the OCR model."; + if ocr_ui.thread_busy if ocr_ui.task.kind == { + case .SCAN; text = "Stopping the OCR scan: finishing the text lines it is reading."; + case .ERASE; text = "Stopping the erase: finishing the capture it is on."; + case .READ; text = "Waiting for OCR to finish reading a capture."; + case .CREATE; text = "Waiting for the OCR model to finish loading."; + case .LABELS_CREATE; text = "Waiting for the label model to finish loading."; + case .LABEL_SET; text = "Waiting for the label list to finish."; + case; text = "Stopping OCR."; + } + case .LABELS; text = "Closing the label model."; + case .STATS; text = "Closing Stats."; + } + waited := cast(float64) (to_nanoseconds(current_time_monotonic()) - atomic_read(*app_shutdown.step_started_ns)) / 1_000_000_000.0; + if waited >= 2 text = tprint("%\n(this step: % s)", text, FormatFloat.{value = waited, trailing_width = 0, zero_removal = .NO}); + return text; +} + app_main_window_ui :: () { push_imgui_window(APP_MAIN_WINDOW_LABEL); + // Once shutdown has started, its thread is stopping the threads these frames hand work to and take + // it from (the viewer's workers, the search group, the import, OCR), and freeing what they share: + // running them now would race it. The exit overlay says what it is waiting on. + if app_exit_shutdown_started() return; viewer_ui(); ocr_ui_frame(); labels_ui_frame(); diff --git a/src/app/exit_shutdown.jai b/src/app/exit_shutdown.jai index d9ddc20..613559e 100644 --- a/src/app/exit_shutdown.jai +++ b/src/app/exit_shutdown.jai @@ -69,12 +69,13 @@ app_exit_shutdown_poll :: () -> bool { return true; } -// For the overlay: what shutdown is doing right now, and how long it has taken so far. -app_exit_shutdown_status :: () -> text: string, elapsed_seconds: float64 { +// For the overlay: what shutdown is doing right now, and how long it has taken so far; detail, when +// the app says what it is waiting on (app_ui_shutdown_status), is a line or two more. Temp. +app_exit_shutdown_status :: () -> text: string, elapsed_seconds: float64, detail: string { elapsed := to_float64_seconds(app.frame_time - exit_shutdown_started_at); if atomic_read(*exit_shutdown_stage) == { - case .FINISHING_APP; return "Saving...", elapsed; - case; return "Closing...", elapsed; + case .FINISHING_APP; return "Closing...", elapsed, app_ui_shutdown_status(); + case; return "Closing...", elapsed, ""; } } diff --git a/src/app/ocr_ui.jai b/src/app/ocr_ui.jai index e2e37fb..e568250 100644 --- a/src/app/ocr_ui.jai +++ b/src/app/ocr_ui.jai @@ -200,12 +200,21 @@ ocr_ui_thread_proc :: (thread: *Thread) -> s64 { case .SCAN; scan := *ocr_ui.scan; + // Cancelling (or closing the app) stops the batch it is on, too, rather than waiting it out: + // a batch of large frames can take minutes. What it had not read is left as it was. + engine := ocr_ui.engine; // a scan needs one; without, each capture fails as it would + if engine engine.cancel = *scan.cancel; + defer if engine engine.cancel = null; position := 0; while position < scan.items.count { while scan.paused && !scan.cancel sleep_milliseconds(100); if scan.cancel break; count := min(OCR_SCAN_BATCH, scan.items.count - position); errors := ocr_read_and_store(array_view(scan.items, position, count), scan.label_set); + if scan.cancel { // the batch was cut short: none of it was stored, and none of it failed + for errors free(it); + break; + } for errors if it { atomic_add(*scan.failed, 1); log_error("[OCR scan] % %", scan.items[position + it_index].path, it); free(it); } atomic_add(*scan.done, count); position += count; diff --git a/src/ui/imgui_exit_overlay.jai b/src/ui/imgui_exit_overlay.jai index 64f7367..13198d9 100644 --- a/src/ui/imgui_exit_overlay.jai +++ b/src/ui/imgui_exit_overlay.jai @@ -1,13 +1,14 @@ // A full-viewport overlay shown while app_exit_shutdown_start is running shutdown in the // background (exit_shutdown.jai), so the window stays live and visibly busy instead of looking -// hung while the app finishes saving. +// hung while the app finishes saving, with what it is waiting on. app_exit_shutdown_overlay_ui :: () { using ImGui; if !app_exit_shutdown_started() return; - text, elapsed := app_exit_shutdown_status(); + text, elapsed, detail := app_exit_shutdown_status(); seconds := FormatFloat.{value = elapsed, trailing_width = 1, zero_removal = .NO}; label := tprint("%1 (%2 s)", text, seconds); + if detail label = tprint("%\n%", label, detail); // what it is waiting on viewport := GetMainViewport(); draw_list := GetForegroundDrawList(viewport);