diff --git a/examples/server/api.md b/examples/server/api.md index 3ade7bee..fc64fb02 100644 --- a/examples/server/api.md +++ b/examples/server/api.md @@ -1037,7 +1037,11 @@ Notes: #### `POST /sdcpp/v1/jobs/{id}/cancel` -Attempts to cancel an accepted job. +Cancels a queued job immediately, or requests interruption of a generating image or +video job. A generating job returns `200 OK` with `cancel_requested: true` and +remains `generating` until execution stops at a cancellation checkpoint. Continue +polling until its status becomes `cancelled`. Cancelled jobs have no result. +Repeated cancellation requests are safe; terminal jobs are returned unchanged. Typical status codes: diff --git a/examples/server/async_jobs.cpp b/examples/server/async_jobs.cpp index edd93593..3e40e506 100644 --- a/examples/server/async_jobs.cpp +++ b/examples/server/async_jobs.cpp @@ -3,13 +3,46 @@ #include "async_jobs.h" #include +#include #include #include +#include #include "common/log.h" #include "common/media_io.h" #include "common/resource_owners.hpp" +class JobCancellationMonitor { + std::mutex mutex_; + std::condition_variable cv_; + bool done_ = false; + std::thread thread_; + +public: + // The caller holds sd_ctx_mutex until this monitor has joined, keeping the + // context alive and preventing cancellation from reaching another request. + JobCancellationMonitor(sd_ctx_t* ctx, const AsyncGenerationJob& job) + : thread_([this, ctx, &job]() { + std::unique_lock lock(mutex_); + while (!done_) { + if (job.cancel_requested.load()) { + // Each generation pass resets the library flag, including ADetailer. + sd_cancel_generation(ctx, SD_CANCEL_ALL); + } + cv_.wait_for(lock, std::chrono::milliseconds(10), [this]() { return done_; }); + } + }) {} + + ~JobCancellationMonitor() { + { + std::lock_guard lock(mutex_); + done_ = true; + } + cv_.notify_one(); + thread_.join(); + } +}; + static void preview_callback(int step, int frame_count, sd_image_t* frames, bool is_noisy, void* data) { (void)is_noisy; if (frame_count <= 0 || frames == nullptr || frames[0].data == nullptr) { @@ -142,6 +175,7 @@ bool cancel_queued_job(AsyncJobManager& manager, AsyncGenerationJob& job) { } manager.queue.erase(new_end, manager.queue.end()); + job.cancel_requested.store(true); job.status = AsyncJobStatus::Cancelled; job.completed_at = unix_timestamp_now_ms(); job.result_images_b64.clear(); @@ -156,13 +190,14 @@ bool cancel_queued_job(AsyncJobManager& manager, AsyncGenerationJob& job) { json make_async_job_json(const AsyncJobManager& manager, const AsyncGenerationJob& job) { json result; - result["id"] = job.id; - result["kind"] = async_job_kind_name(job.kind); - result["status"] = async_job_status_name(job.status); - result["created"] = job.created_at; - result["started"] = job.started_at == 0 ? json(nullptr) : json(job.started_at); - result["completed"] = job.completed_at == 0 ? json(nullptr) : json(job.completed_at); - result["queue_position"] = 0; + result["id"] = job.id; + result["kind"] = async_job_kind_name(job.kind); + result["status"] = async_job_status_name(job.status); + result["cancel_requested"] = job.cancel_requested.load(); + result["created"] = job.created_at; + result["started"] = job.started_at == 0 ? json(nullptr) : json(job.started_at); + result["completed"] = job.completed_at == 0 ? json(nullptr) : json(job.completed_at); + result["queue_position"] = 0; if (job.status == AsyncJobStatus::Queued) { size_t position = 1; @@ -235,6 +270,10 @@ bool execute_img_gen_job(ServerRuntime& runtime, { std::lock_guard lock(*runtime.sd_ctx_mutex); + if (job.cancel_requested.load()) { + return false; + } + JobCancellationMonitor cancellation(runtime.sd_ctx, job); set_preview_callback(job); sd_image_t* raw_results = nullptr; int num_results = 0; @@ -244,6 +283,9 @@ bool execute_img_gen_job(ServerRuntime& runtime, } clear_preview_callback(); results.adopt(raw_results, num_results); + if (job.cancel_requested.load()) { + return false; + } if (!results.empty() && !apply_server_adetailer(runtime, job.img_gen, params, results)) { error_message = "ADetailer failed"; return false; @@ -251,7 +293,7 @@ bool execute_img_gen_job(ServerRuntime& runtime, } const int num_results = results.count(); - if (num_results <= 0) { + if (job.cancel_requested.load() || num_results <= 0) { error_message = "generate_image returned no results"; return false; } @@ -314,6 +356,10 @@ bool execute_vid_gen_job(ServerRuntime& runtime, { std::lock_guard lock(*runtime.sd_ctx_mutex); + if (job.cancel_requested.load()) { + return false; + } + JobCancellationMonitor cancellation(runtime.sd_ctx, job); set_preview_callback(job); sd_image_t* raw_results = nullptr; if (!generate_video(runtime.sd_ctx, ¶ms, &raw_results, &num_results, &generated_audio, &output_fps)) { @@ -324,7 +370,7 @@ bool execute_vid_gen_job(ServerRuntime& runtime, } num_results = results.count(); - if (num_results <= 0) { + if (job.cancel_requested.load() || num_results <= 0) { free_sd_audio(generated_audio); error_message = "generate_video returned no results"; return false; @@ -410,7 +456,11 @@ void async_job_worker(ServerRuntime& runtime) { } job->completed_at = unix_timestamp_now_ms(); - if (ok) { + if (job->cancel_requested.load()) { + job->status = AsyncJobStatus::Cancelled; + job->error_code = "cancelled"; + job->error_message = "job cancelled by client"; + } else if (ok) { job->status = AsyncJobStatus::Completed; job->result_images_b64 = std::move(output_images); job->result_media_b64 = std::move(output_media_b64); diff --git a/examples/server/async_jobs.h b/examples/server/async_jobs.h index 049a9d73..aa54370b 100644 --- a/examples/server/async_jobs.h +++ b/examples/server/async_jobs.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -32,9 +33,10 @@ struct AsyncGenerationJob { std::string id; AsyncJobKind kind = AsyncJobKind::ImgGen; AsyncJobStatus status = AsyncJobStatus::Queued; - int64_t created_at = unix_timestamp_now_ms(); - int64_t started_at = 0; - int64_t completed_at = 0; + std::atomic cancel_requested{false}; + int64_t created_at = unix_timestamp_now_ms(); + int64_t started_at = 0; + int64_t completed_at = 0; ImgGenJobRequest img_gen; VidGenJobRequest vid_gen; std::vector result_images_b64; diff --git a/examples/server/frontend b/examples/server/frontend index e36e34a2..34e5ad6e 160000 --- a/examples/server/frontend +++ b/examples/server/frontend @@ -1 +1 @@ -Subproject commit e36e34a2e2204e3df654b16cadc112d8ce8f3174 +Subproject commit 34e5ad6edcaf827b7fe1e9ffa55ee86cd2fc43d2 diff --git a/examples/server/routes_sdcpp.cpp b/examples/server/routes_sdcpp.cpp index 8eb9c9ab..bea6b69c 100644 --- a/examples/server/routes_sdcpp.cpp +++ b/examples/server/routes_sdcpp.cpp @@ -206,7 +206,7 @@ static json make_img_gen_features_json() { {"hires", true}, {"cache", true}, {"cancel_queued", true}, - {"cancel_generating", false}, + {"cancel_generating", true}, {"preview", true}, }; } @@ -221,7 +221,7 @@ static json make_vid_gen_features_json() { {"vae_tiling", true}, {"cache", true}, {"cancel_queued", true}, - {"cancel_generating", false}, + {"cancel_generating", true}, {"preview", true}, }; } @@ -380,8 +380,8 @@ static json make_capabilities_json(ServerRuntime& runtime) { json top_level_defaults = json::object(); json top_level_output_formats = json::array(); json top_level_features = { - {"cancel_queued", true}, - {"cancel_generating", false}, + {"cancel_queued", true}, + {"cancel_generating", true}, }; std::string current_mode = ""; if (supports_img) { @@ -840,8 +840,9 @@ void register_sdcpp_api_endpoints(httplib::Server& svr, ServerRuntime& rt) { } if (job.status == AsyncJobStatus::Generating) { - res.status = 409; - res.set_content(R"({"error":"job is currently generating and cannot be interrupted yet"})", "application/json"); + job.cancel_requested.store(true); + res.status = 200; + res.set_content(make_async_job_json(manager, job).dump(), "application/json"); return; }