[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.
This commit is contained in:
Musa Mahmood 2026-10-09 23:37:35 +01:00
parent baa188b0e4
commit 775c2873e4
7 changed files with 100 additions and 9 deletions

View File

@ -29,7 +29,7 @@ APP_NAME :: "mcap";
APP_FOLDER_NAME :: "mcap"; APP_FOLDER_NAME :: "mcap";
GIT_BRANCH :: "check"; GIT_BRANCH :: "check";
GIT_HASH :: "-"; GIT_HASH :: "-";
VERSION :: "0.0.9"; VERSION :: "0.0.10";
BUILD_DATE :: "-"; BUILD_DATE :: "-";
DEBUG :: true; DEBUG :: true;
COMPILE_TESTS :: true; COMPILE_TESTS :: true;

View File

@ -8,7 +8,7 @@ APP_NAME :: "mcap";
APP_FOLDER_NAME :: "mcap"; APP_FOLDER_NAME :: "mcap";
APP_BUNDLE_IDENTIFIER :: "com.example.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 // 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. // builds never write over the ones you run by hand.
build_directory := "bin"; build_directory := "bin";

View File

@ -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 rec_batch: s32; // text lines per recognizer run: the model's rec_batch_gpu or rec_batch_cpu
options: Ocr_Options; options: Ocr_Options;
stats: Ocr_Stats; // since creation, or the last ocr_reset_stats 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). // 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)); atomic_add(*engine.stats.detect_us, to_microseconds(current_time_monotonic() - start));
if work.error work.error = copy_string(work.error,, context.default_allocator); if work.error work.error = copy_string(work.error,, context.default_allocator);
}, per_image.data); }, 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. // Recognition: every line of every image, batched by width so little of each batch is padding.
jobs: [..] Line_Job; 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)); 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 if !ok { b.failed = true; b.error = copy_string(error,, context.default_allocator); } // the last error wins; any is enough to say why
}, *batches); }, *batches);
if ocr_cancelled(engine) {
free(batches.error,, context.default_allocator);
return ocr_all_cancelled(results, allocator);
}
if classify.failed && !batches.failed { if classify.failed && !batches.failed {
batches.failed = true; batches.failed = true;
batches.error = copy_string(classify.error,, context.default_allocator); 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 #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_Work :: struct {
image: *Ocr_Image; image: *Ocr_Image;
boxes: [..] Detected_Box; 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; context.allocator = context.default_allocator;
shared := cast(*Shared) thread.data; shared := cast(*Shared) thread.data;
while true { while true {
if ocr_cancelled(shared.engine) break; // what is left fails as cancelled (ocr_process)
index := atomic_add(*shared.next, 1); index := atomic_add(*shared.next, 1);
if index >= shared.count break; if index >= shared.count break;
shared.proc(shared.engine, index, shared.data); shared.proc(shared.engine, index, shared.data);

View File

@ -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 // 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_ui_shutdown :: () {
app_shutdown_step(.IMPORT);
import_shutdown(); // before the viewer: it hands the viewer what it writes import_shutdown(); // before the viewer: it hands the viewer what it writes
app_shutdown_step(.VIEWER);
viewer_shutdown(); viewer_shutdown();
app_shutdown_step(.OCR);
ocr_ui_shutdown(); ocr_ui_shutdown();
app_shutdown_step(.LABELS);
labels_ui_shutdown(); // after the OCR thread and the background workers have stopped labels_ui_shutdown(); // after the OCR thread and the background workers have stopped
app_shutdown_step(.STATS);
stats_ui_shutdown(); 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 :: () { app_main_window_ui :: () {
push_imgui_window(APP_MAIN_WINDOW_LABEL); 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(); viewer_ui();
ocr_ui_frame(); ocr_ui_frame();
labels_ui_frame(); labels_ui_frame();

View File

@ -69,12 +69,13 @@ app_exit_shutdown_poll :: () -> bool {
return true; return true;
} }
// For the overlay: what shutdown is doing right now, and how long it has taken so far. // For the overlay: what shutdown is doing right now, and how long it has taken so far; detail, when
app_exit_shutdown_status :: () -> text: string, elapsed_seconds: float64 { // 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); elapsed := to_float64_seconds(app.frame_time - exit_shutdown_started_at);
if atomic_read(*exit_shutdown_stage) == { if atomic_read(*exit_shutdown_stage) == {
case .FINISHING_APP; return "Saving...", elapsed; case .FINISHING_APP; return "Closing...", elapsed, app_ui_shutdown_status();
case; return "Closing...", elapsed; case; return "Closing...", elapsed, "";
} }
} }

View File

@ -200,12 +200,21 @@ ocr_ui_thread_proc :: (thread: *Thread) -> s64 {
case .SCAN; case .SCAN;
scan := *ocr_ui.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; position := 0;
while position < scan.items.count { while position < scan.items.count {
while scan.paused && !scan.cancel sleep_milliseconds(100); while scan.paused && !scan.cancel sleep_milliseconds(100);
if scan.cancel break; if scan.cancel break;
count := min(OCR_SCAN_BATCH, scan.items.count - position); count := min(OCR_SCAN_BATCH, scan.items.count - position);
errors := ocr_read_and_store(array_view(scan.items, position, count), scan.label_set); 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); } 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); atomic_add(*scan.done, count);
position += count; position += count;

View File

@ -1,13 +1,14 @@
// A full-viewport overlay shown while app_exit_shutdown_start is running shutdown in the // 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 // 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; app_exit_shutdown_overlay_ui :: () { using ImGui;
if !app_exit_shutdown_started() return; 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}; seconds := FormatFloat.{value = elapsed, trailing_width = 1, zero_removal = .NO};
label := tprint("%1 (%2 s)", text, seconds); label := tprint("%1 (%2 s)", text, seconds);
if detail label = tprint("%\n%", label, detail); // what it is waiting on
viewport := GetMainViewport(); viewport := GetMainViewport();
draw_list := GetForegroundDrawList(viewport); draw_list := GetForegroundDrawList(viewport);