Arrêt de génération et reclassification optionnelle depuis l'interface
- POST /api/stop : SIGTERM au pipeline (groupe isolé via start_new_session), SIGKILL du groupe après 15 s ; forwardé au worker en mode deux machines - « Régénérer » n'implique plus la reclassification : le DTM en cache est réutilisé tant que la méthode ne change pas ; nouvelle case « Reclasser le sol » (--force-classification) et rappel de la classification existante - Tâche marquée « Interrompu » (returncode négatif ou nul) au lieu d'Échec
This commit is contained in:
@ -1216,6 +1216,10 @@ _HTML_TEMPLATE = """<!DOCTYPE html>
|
||||
<label class="gen-opt">
|
||||
<input type="checkbox" id="genRegen"> Régénérer les tuiles existantes
|
||||
</label>
|
||||
<label class="gen-opt" title="Par défaut la classification du sol existante est conservée (DTM réutilisé). Cochez pour la relancer sur toutes les tuiles ; choisir une autre méthode ci-dessous reclassifie déjà les tuiles concernées.">
|
||||
<input type="checkbox" id="genReclass"> Reclasser le sol
|
||||
</label>
|
||||
<div class="gen-hint" id="genClassInfo" hidden></div>
|
||||
<label class="gen-opt col" title="Toutes pré-sélectionnées — Ctrl+clic pour modifier la sélection">
|
||||
<span>Visualisations :</span>
|
||||
<select id="genViz" class="gen-select" multiple size="8">
|
||||
@ -1250,6 +1254,7 @@ _HTML_TEMPLATE = """<!DOCTYPE html>
|
||||
<div class="queue-head">
|
||||
<span class="panel-title">File de génération</span>
|
||||
<span id="genQueueCount"></span>
|
||||
<button id="genStop" class="icon-btn" title="Arrêter la génération en cours" hidden>■</button>
|
||||
<button id="genQueueClear" class="icon-btn" title="Retirer les tâches terminées">Vider</button>
|
||||
</div>
|
||||
<div id="genQueueBody"></div>
|
||||
@ -1638,6 +1643,7 @@ body.zooming .ltile { will-change: transform; }
|
||||
.gen-opt.col { flex-direction: column; align-items: stretch; gap: 4px; }
|
||||
.gen-opt.col > span { color: var(--muted); font-size: 11px; }
|
||||
.gen-opt select { flex: 1; min-width: 0; }
|
||||
.gen-hint { color: var(--muted); font-size: 11px; padding: 0 2px; }
|
||||
.gen-run { display: flex; gap: 8px; margin-top: 2px; }
|
||||
.gen-run #genGo { flex: 1; }
|
||||
|
||||
@ -1658,7 +1664,7 @@ body.zooming .ltile { will-change: transform; }
|
||||
background: var(--accent); animation: gen-pulse 1.1s ease-in-out infinite;
|
||||
}
|
||||
.gen-task.done .gen-task-dot { background: #39d98a; }
|
||||
.gen-task.failed .gen-task-dot { background: var(--danger); }
|
||||
.gen-task.failed .gen-task-dot, .gen-task.stopped .gen-task-dot { background: var(--danger); }
|
||||
@keyframes gen-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.25; } }
|
||||
.gen-task-label {
|
||||
font-size: 12px; flex: 1; min-width: 120px;
|
||||
@ -1667,7 +1673,7 @@ body.zooming .ltile { will-change: transform; }
|
||||
.gen-task-state { font-size: 11px; white-space: nowrap; }
|
||||
.gen-task.running .gen-task-state { color: var(--accent); }
|
||||
.gen-task.done .gen-task-state { color: #39d98a; }
|
||||
.gen-task.failed .gen-task-state { color: var(--danger); }
|
||||
.gen-task.failed .gen-task-state, .gen-task.stopped .gen-task-state { color: var(--danger); }
|
||||
.gen-task-time {
|
||||
font-size: 10.5px; color: var(--muted); font-variant-numeric: tabular-nums;
|
||||
}
|
||||
@ -2404,6 +2410,7 @@ function regenerateCurrentTile() {
|
||||
document.getElementById('genRegen').checked = true;
|
||||
genGo.style.display = '';
|
||||
showGenBar('1 tuile à régénérer — choisissez les options puis lancez');
|
||||
updateGenClassInfo();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
@ -2419,12 +2426,15 @@ const genMsg = document.getElementById('genMsg');
|
||||
const genGo = document.getElementById('genGo');
|
||||
const genCancel = document.getElementById('genCancel');
|
||||
const genRegen = document.getElementById('genRegen');
|
||||
const genReclass = document.getElementById('genReclass');
|
||||
const genClassInfo = document.getElementById('genClassInfo');
|
||||
const genClass = document.getElementById('genClass');
|
||||
const genIgnClasses = document.getElementById('genIgnClasses');
|
||||
const genViz = document.getElementById('genViz');
|
||||
const genBareEarth = document.getElementById('genBareEarth');
|
||||
const genQueue = document.getElementById('genQueue');
|
||||
const genBadge = document.getElementById('genBadge');
|
||||
const genStop = document.getElementById('genStop');
|
||||
const genQueueBody = document.getElementById('genQueueBody');
|
||||
const genQueueClear = document.getElementById('genQueueClear');
|
||||
const genQueueCount = document.getElementById('genQueueCount');
|
||||
@ -2442,6 +2452,24 @@ function showGenBar(msg) {
|
||||
genBar.classList.add('show');
|
||||
}
|
||||
|
||||
// Classification déjà présente sur les tuiles visées : guide le choix
|
||||
// « Reclasser le sol » (décoché = la classification existante est conservée).
|
||||
function updateGenClassInfo() {
|
||||
if (!genClassInfo) return;
|
||||
const methods = new Set();
|
||||
for (const pair of (pendingTiles || [])) {
|
||||
for (const t of TILES) {
|
||||
if (t.col === pair[0] && t.row === pair[1] && t.meta && t.meta.method)
|
||||
methods.add(t.meta.method);
|
||||
}
|
||||
}
|
||||
if (!methods.size) { genClassInfo.hidden = true; return; }
|
||||
genClassInfo.textContent = 'Classification existante : ' +
|
||||
[...methods].map(methodLabel).join(', ') +
|
||||
' — conservée si « Reclasser le sol » est décoché et que la méthode ne change pas';
|
||||
genClassInfo.hidden = false;
|
||||
}
|
||||
|
||||
fetch('/api/status').then(r => {
|
||||
apiAvailable = r.ok;
|
||||
if (!apiAvailable) hideExportTab();
|
||||
@ -2512,6 +2540,7 @@ map.on('mouseup', e => {
|
||||
if (drawRect) { drawRect.remove(); drawRect = null; }
|
||||
pendingTiles = (d.cells || []).map(c => [c.col, c.row]);
|
||||
pendingCompleteAll = false; // zone dessinée : le scope global est levé
|
||||
updateGenClassInfo();
|
||||
(d.cells || []).forEach(c => ghostCells.push(
|
||||
L.polygon(c.corners, { color: '#e94560', weight: 1, dashArray: '4 4', fillOpacity: 0.08, pane: 'genDraw' }).addTo(map)
|
||||
));
|
||||
@ -2563,6 +2592,7 @@ function toggleGenSel(t) {
|
||||
pendingRegen = true;
|
||||
pendingCompleteAll = false;
|
||||
updateGenSelBar();
|
||||
updateGenClassInfo();
|
||||
}
|
||||
|
||||
function setGenSelMode(on) {
|
||||
@ -2623,6 +2653,7 @@ btnComplete.addEventListener('click', () => {
|
||||
}).then(r => r.json()).then(d => {
|
||||
pendingTiles = (d.cells || []).map(c => [c.col, c.row]);
|
||||
pendingCompleteAll = true;
|
||||
updateGenClassInfo();
|
||||
(d.cells || []).forEach(c => ghostCells.push(
|
||||
L.polygon(c.corners, { color: '#e94560', weight: 1, dashArray: '4 4', fillOpacity: 0.08, pane: 'genDraw' }).addTo(map)
|
||||
));
|
||||
@ -2652,7 +2683,8 @@ genGo.addEventListener('click', () => {
|
||||
all_missing: pendingCompleteAll,
|
||||
ground_class: genClass ? genClass.value : 'ign',
|
||||
ign_classes: genIgnClasses ? genIgnClasses.value : 'sol',
|
||||
bare_earth: genBareEarth ? genBareEarth.checked : false })
|
||||
bare_earth: genBareEarth ? genBareEarth.checked : false,
|
||||
reclassify: genReclass ? genReclass.checked : false })
|
||||
}).then(r => r.ok ? r.json() : r.json().then(j => Promise.reject(j.detail || ('HTTP ' + r.status))))
|
||||
.then(() => {
|
||||
const verb = isRegen ? 'régénération'
|
||||
@ -2669,6 +2701,7 @@ genCancel.addEventListener('click', () => {
|
||||
genBar.classList.remove('show');
|
||||
clearGhosts();
|
||||
pendingTiles = []; pendingRegen = false; pendingCompleteAll = false;
|
||||
updateGenClassInfo();
|
||||
clearGenSel();
|
||||
if (genMode) exitGenMode();
|
||||
});
|
||||
@ -2943,6 +2976,8 @@ function genTaskEl(t) {
|
||||
: 'En cours…';
|
||||
} else if (t.state === 'done') {
|
||||
state.textContent = 'Terminé';
|
||||
} else if (t.state === 'stopped') {
|
||||
state.textContent = 'Interrompu';
|
||||
} else {
|
||||
state.textContent = 'Échec (code ' + t.returncode + ')';
|
||||
}
|
||||
@ -2988,6 +3023,7 @@ function renderGenQueue() {
|
||||
const running = genTasks.filter(t => t.state === 'running').length;
|
||||
genBadge.hidden = running === 0;
|
||||
genBadge.textContent = running > 0 ? String(running) : '';
|
||||
if (genStop) { genStop.hidden = running === 0; genStop.disabled = false; }
|
||||
genQueueCount.textContent = genTasks.length
|
||||
? (running ? running + ' en cours / ' + genTasks.length : genTasks.length + ' tâche(s)')
|
||||
: '';
|
||||
@ -3008,7 +3044,10 @@ function finishRunningTasks(d) {
|
||||
let changed = false;
|
||||
for (const t of genTasks) {
|
||||
if (t.state !== 'running') continue;
|
||||
t.state = d.returncode === 0 ? 'done' : 'failed';
|
||||
// returncode négatif = tué par /api/stop ; null = serveur relancé en plein
|
||||
// run (job disparu) : dans les deux cas la tâche est interrompue, pas échouée.
|
||||
t.state = d.returncode === 0 ? 'done'
|
||||
: (d.returncode === null || d.returncode < 0) ? 'stopped' : 'failed';
|
||||
t.returncode = d.returncode;
|
||||
changed = true;
|
||||
}
|
||||
@ -3154,6 +3193,19 @@ genQueueClear.addEventListener('click', () => {
|
||||
saveGenTasks(); renderGenQueue();
|
||||
});
|
||||
|
||||
// Arrêt de la génération en cours : SIGTERM au pipeline côté serveur, qui
|
||||
// nettoie ses workers et ses processus PDAL ; le sondage marque ensuite la
|
||||
// tâche « Interrompu » (returncode négatif).
|
||||
if (genStop) genStop.addEventListener('click', () => {
|
||||
genStop.disabled = true;
|
||||
fetch('/api/stop', { method: 'POST' })
|
||||
.then(r => r.ok ? r.json() : r.json().then(j => Promise.reject(j.detail || ('HTTP ' + r.status))))
|
||||
.catch(msg => {
|
||||
genStop.disabled = false;
|
||||
showGenBar('Erreur arrêt : ' + msg);
|
||||
});
|
||||
});
|
||||
|
||||
renderGenQueue();
|
||||
// Reprend le suivi si une tâche tournait avant un rechargement de la page,
|
||||
// adopte une génération lancée hors interface (API directe), ou réaffiche le
|
||||
@ -3175,7 +3227,8 @@ else fetch('/api/status').then(r => r.ok ? r.json() : null).then(s => {
|
||||
genTasks.push({
|
||||
id: Date.now(), label: 'Dernière génération',
|
||||
time: now, started: s.started,
|
||||
state: s.returncode === 0 ? 'done' : 'failed', returncode: s.returncode,
|
||||
state: s.returncode === 0 ? 'done' : s.returncode < 0 ? 'stopped' : 'failed',
|
||||
returncode: s.returncode,
|
||||
log: [], logOpen: false, tiles: s.tiles,
|
||||
});
|
||||
saveGenTasks(); renderGenQueue();
|
||||
|
||||
@ -222,10 +222,13 @@ def test_generate_all_missing_empty(tmp_path, monkeypatch):
|
||||
|
||||
|
||||
def test_build_command_regenerate():
|
||||
"""regenerate=True ajoute --force --force-classification à la commande."""
|
||||
"""regenerate --force seul (classification conservée) ; reclassify ajoute --force-classification."""
|
||||
from lidar_pipeline.webapp import _build_command
|
||||
cmd = " ".join(_build_command([(1054, 6882)], regenerate=True))
|
||||
assert "--force" in cmd
|
||||
assert "--force-classification" not in cmd
|
||||
cmd = " ".join(_build_command([(1054, 6882)], regenerate=True, reclassify=True))
|
||||
assert "--force" in cmd
|
||||
assert "--force-classification" in cmd
|
||||
cmd = " ".join(_build_command([(1054, 6882)]))
|
||||
assert "--force" not in cmd
|
||||
@ -239,14 +242,56 @@ def test_build_command_ground_classification():
|
||||
cmd = _build_command([(1054, 6882)])
|
||||
i = cmd.index("--ground-classification")
|
||||
assert cmd[i + 1] == "ign"
|
||||
# Chaque méthode valide est transmise telle quelle, avec ou sans régénération
|
||||
# Chaque méthode valide est transmise telle quelle ; --force suit
|
||||
# regenerate, --force-classification suit reclassify (indépendants)
|
||||
for method in GROUND_CLASS_METHODS:
|
||||
for regenerate in (False, True):
|
||||
cmd = _build_command([(1054, 6882)], regenerate=regenerate, ground_class=method)
|
||||
i = cmd.index("--ground-classification")
|
||||
assert cmd[i + 1] == method
|
||||
assert ("--force" in cmd) == regenerate
|
||||
assert ("--force-classification" in cmd) == regenerate
|
||||
for reclassify in (False, True):
|
||||
cmd = _build_command([(1054, 6882)], regenerate=regenerate,
|
||||
ground_class=method, reclassify=reclassify)
|
||||
i = cmd.index("--ground-classification")
|
||||
assert cmd[i + 1] == method
|
||||
assert ("--force" in cmd) == regenerate
|
||||
assert ("--force-classification" in cmd) == reclassify
|
||||
|
||||
|
||||
def test_stop_generation(monkeypatch):
|
||||
"""/api/stop termine le processus en cours (SIGTERM) ou refuse (409)."""
|
||||
import time
|
||||
from fastapi import HTTPException
|
||||
import lidar_pipeline.webapp as webapp
|
||||
terminated = []
|
||||
|
||||
class FakeProc:
|
||||
pid = 4242
|
||||
def poll(self):
|
||||
return None # en cours
|
||||
def terminate(self):
|
||||
terminated.append("SIGTERM")
|
||||
def wait(self, timeout=None):
|
||||
terminated.append("wait")
|
||||
|
||||
saved = {k: webapp._job.get(k) for k in ("proc", "started", "returncode", "cmd", "finished")}
|
||||
try:
|
||||
webapp._job.update({"proc": FakeProc(), "started": time.time(),
|
||||
"returncode": None, "cmd": ["x"], "finished": None})
|
||||
assert webapp.stop_generation() == {"arrêt": "demandé"}
|
||||
assert "SIGTERM" in terminated
|
||||
# Le fil d'escalade peut mettre un instant avant son wait : laisser
|
||||
# la file de threads vider avant de restaurer l'état du job.
|
||||
for _ in range(50):
|
||||
if "wait" in terminated:
|
||||
break
|
||||
time.sleep(0.02)
|
||||
# Sans job en cours : refus propre
|
||||
webapp._job.update({"proc": None})
|
||||
try:
|
||||
webapp.stop_generation()
|
||||
assert False, "une HTTPException était attendue"
|
||||
except HTTPException as e:
|
||||
assert e.status_code == 409
|
||||
finally:
|
||||
webapp._job.update(saved)
|
||||
|
||||
|
||||
def test_build_command_default_viz_aspect():
|
||||
@ -574,6 +619,7 @@ def test_generate_forwards_to_remote(monkeypatch):
|
||||
assert result["demarré"] is True and result["distant"] is True
|
||||
assert calls == [("POST", "/api/generate",
|
||||
{"tiles": [[1054, 6882]], "regenerate": False,
|
||||
"reclassify": False,
|
||||
"ground_class": "ign", "ign_classes": "sol",
|
||||
"bare_earth": False, "viz": ["aspect"],
|
||||
"all_missing": False})]
|
||||
|
||||
@ -18,12 +18,19 @@ le téléchargement IGN). Endpoints :
|
||||
demandées, option regenerate=true pour inclure les
|
||||
tuiles complètes
|
||||
POST /api/generate → télécharge (géoplateforme IGN) puis traite des cellules
|
||||
(option regenerate=true ajoute --force --force-classification ;
|
||||
option ground_class choisit la méthode de classification du sol ;
|
||||
(option regenerate=true ajoute --force : les visualisations
|
||||
sont refaites mais la classification existante est
|
||||
conservée ; option reclassify=true ajoute en plus
|
||||
--force-classification pour relancer la classification
|
||||
du sol — sinon changer simplement ground_class
|
||||
reclassifie déjà les tuiles dont la méthode diffère ;
|
||||
option all_missing=true traite toutes les dalles présentes
|
||||
dans input/ qui manquent les visualisations demandées, sans
|
||||
téléchargement ; sans --force, le pipeline ne génère que
|
||||
les visualisations manquantes des tuiles existantes)
|
||||
POST /api/stop → arrête la génération en cours (SIGTERM au pipeline, qui
|
||||
nettoie ses workers et processus PDAL ; SIGKILL du groupe
|
||||
en repli après 15 s)
|
||||
POST /api/export → assemble des dalles adjacentes en une image (PNG/JPEG/
|
||||
WebP) ou un PDF multi-couches, consultable sur téléphone
|
||||
(module export.py — mosaïque sans couture, habillage
|
||||
@ -52,6 +59,7 @@ import logging
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
@ -444,7 +452,15 @@ class PreviewRequest(BaseModel):
|
||||
|
||||
class GenerateRequest(BaseModel):
|
||||
tiles: list = Field(..., description="liste [col, row] (entiers km L93)")
|
||||
regenerate: bool = Field(False, description="Régénérer les tuiles déjà générées")
|
||||
regenerate: bool = Field(False, description="Régénérer les tuiles déjà générées "
|
||||
"(visualisations refaites, classification conservée)")
|
||||
reclassify: bool = Field(False,
|
||||
description="Relancer la classification du sol même si "
|
||||
"la méthode demandée est déjà en cache "
|
||||
"(--force-classification). Défaut : conserver "
|
||||
"la classification existante ; choisir une "
|
||||
"autre méthode reclassifie de toute façon "
|
||||
"les tuiles concernées")
|
||||
ground_class: str = Field("ign",
|
||||
description="Méthode de classification du sol : "
|
||||
"auto, ign, smrf, csf")
|
||||
@ -907,16 +923,20 @@ def preview(req: PreviewRequest):
|
||||
return {"count": len(todo), "capped": capped, "cells": todo}
|
||||
|
||||
|
||||
def _build_command(tiles, regenerate=False, ground_class="ign", bare_earth=False, ign_classes="sol", viz=None):
|
||||
def _build_command(tiles, regenerate=False, ground_class="ign", bare_earth=False, ign_classes="sol", viz=None, reclassify=False):
|
||||
"""Commande de génération : téléchargement IGN + traitement des fichiers.
|
||||
|
||||
La classification du sol est choisie via `ground_class` (défaut : "ign",
|
||||
pré-classification IGN ; le pipeline bascule sur SMRF si un fichier ne la
|
||||
contient pas). Avec ign_classes, on choisit les classes LAS extraites
|
||||
pour le MNT (mode pur, ex. "sol,unclassified" pour combler les trous
|
||||
sans retouche). Avec regenerate=True, force la reclassification et la
|
||||
régénération des visualisations des tuiles déjà présentes. Avec
|
||||
bare_earth=True, le DTM est ramené au retour le plus bas de chaque
|
||||
sans retouche). Avec regenerate=True, les visualisations des tuiles déjà
|
||||
présentes sont refaites (--force) mais leur classification est conservée :
|
||||
le pipeline réutilise le DTM en cache quand la méthode ne change pas.
|
||||
Avec reclassify=True, --force-classification relance la classification
|
||||
même à méthode inchangée (sinon, choisir une méthode différente via
|
||||
ground_class/ign_classes reclassifie déjà les tuiles concernées).
|
||||
Avec bare_earth=True, le DTM est ramené au retour le plus bas de chaque
|
||||
cellule (sol nu). Avec viz, on choisit les visualisations générées
|
||||
(noms d'étapes du pipeline, ex. ["aspect", "wavelet", "slope"] ;
|
||||
défaut : aspect).
|
||||
@ -933,7 +953,9 @@ def _build_command(tiles, regenerate=False, ground_class="ign", bare_earth=False
|
||||
if bare_earth:
|
||||
cmd += ["--bare-earth"]
|
||||
if regenerate:
|
||||
cmd += ["--force", "--force-classification"]
|
||||
cmd += ["--force"]
|
||||
if reclassify:
|
||||
cmd += ["--force-classification"]
|
||||
if os.environ.get("LIDAR_GPU", "") == "1":
|
||||
cmd += ["-g", "all", "-w", os.environ.get("LIDAR_WORKERS", "2")]
|
||||
# Carte régénérée après chaque tuile terminée : la webapp l'affiche en
|
||||
@ -989,7 +1011,7 @@ def generate(req: GenerateRequest):
|
||||
proc = _job["proc"]
|
||||
if proc is not None and proc.poll() is None:
|
||||
raise HTTPException(409, "une génération est déjà en cours")
|
||||
cmd = _build_command(tiles, regenerate=req.regenerate, ground_class=req.ground_class, bare_earth=req.bare_earth, ign_classes=req.ign_classes, viz=viz)
|
||||
cmd = _build_command(tiles, regenerate=req.regenerate, ground_class=req.ground_class, bare_earth=req.bare_earth, ign_classes=req.ign_classes, viz=viz, reclassify=req.reclassify)
|
||||
LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
log_fh = open(LOG_FILE, "w", encoding="utf-8")
|
||||
# Nouveau run : journal d'événements remis à zéro (les tuiles affichées
|
||||
@ -998,8 +1020,13 @@ def generate(req: GenerateRequest):
|
||||
reset_events(OUTPUT_DIR)
|
||||
_job.update({"proc": None, "started": time.time(), "returncode": None, "cmd": cmd, "finished": None})
|
||||
_save_job_state()
|
||||
# start_new_session : le pipeline et ses workers/PDAL forment leur
|
||||
# propre groupe de processus — /api/stop peut le tuer en bloc sans
|
||||
# toucher la webapp, et le gestionnaire SIGTERM du pipeline (killpg)
|
||||
# reste confiné à son groupe.
|
||||
p = subprocess.Popen(cmd, stdout=log_fh, stderr=subprocess.STDOUT,
|
||||
cwd="/app" if Path("/app").exists() else None)
|
||||
cwd="/app" if Path("/app").exists() else None,
|
||||
start_new_session=True)
|
||||
|
||||
def _watch():
|
||||
rc = p.wait()
|
||||
@ -1014,6 +1041,46 @@ def generate(req: GenerateRequest):
|
||||
return {"demarré": True, "tuiles": len(tiles), "commande": " ".join(cmd)}
|
||||
|
||||
|
||||
@app.post("/api/stop", dependencies=[Depends(_require_token),
|
||||
Depends(_require_lan_for_generation)])
|
||||
def stop_generation():
|
||||
"""Arrête la génération en cours.
|
||||
|
||||
SIGTERM au pipeline : son gestionnaire de signal nettoie ses workers et
|
||||
ses processus PDAL (killpg sur son propre groupe, cf. cli.py). Si le
|
||||
processus ne meurt pas dans les 15 s (worker bloqué), SIGKILL du groupe
|
||||
entier. L'état final (returncode négatif) est enregistré par le fil de
|
||||
surveillance du job.
|
||||
"""
|
||||
if GENERATION_URL:
|
||||
# Webapp légère : l'arrêt concerne le pipeline de la machine distante.
|
||||
return _proxy_api("POST", "/api/stop")
|
||||
with _job_lock:
|
||||
proc = _job["proc"]
|
||||
if proc is None or proc.poll() is not None:
|
||||
raise HTTPException(409, "aucune génération en cours")
|
||||
try:
|
||||
proc.terminate() # SIGTERM → nettoyage propre du pipeline
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def _escalate():
|
||||
try:
|
||||
proc.wait(timeout=15)
|
||||
except subprocess.TimeoutExpired:
|
||||
try:
|
||||
os.killpg(proc.pid, signal.SIGKILL)
|
||||
except (OSError, ProcessLookupError):
|
||||
try:
|
||||
proc.kill()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
threading.Thread(target=_escalate, daemon=True).start()
|
||||
logger.info("Arrêt de la génération demandé (SIGTERM au pipeline)")
|
||||
return {"arrêt": "demandé"}
|
||||
|
||||
|
||||
# --- Export multi-dalles (mosaïque image/PDF pour téléphone) ---------------
|
||||
_export_lock = threading.Lock()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user