66vezes na mesma gravação) chegou sem nenhum rastro da causa: o process.log só
77tem o que foi impresso ANTES da trava.
88
9- Esta sentinela roda DENTRO do filho: `faulthandler.dump_traceback_later` é uma
10- thread em C que despeja a pilha de TODAS as threads direto no fd quando o
11- processo fica `idle_s` sem "bater" - funciona mesmo com o GIL preso num
12- deadlock, que é exatamente o cenário que interessa. Batimentos: cada escrita no
13- stdout (as linhas de progresso do process.log), cada mudança de estágio e cada
14- bloco da diarização. O resumo (espera legítima de rede, sem CPU nem log) fica
15- SUSPENSO - lá o watchdog do pai já respeita o timeout configurado.
9+ Esta sentinela roda DENTRO do filho: uma thread daemon (o mesmo desenho do
10+ watchdog da GUI, scriba/watchdog.py) confere a cada instante há quanto tempo o
11+ processo não "bate"; passado `idle_s`, despeja a pilha de TODAS as threads com
12+ `faulthandler.dump_traceback`. Batimentos: cada escrita no stdout (as linhas de
13+ progresso do process.log), cada mudança de estágio e cada bloco da diarização.
14+ O resumo (espera legítima de rede, sem CPU nem log) fica SUSPENSO - lá o
15+ watchdog do pai já respeita o timeout configurado.
16+
17+ Por que NÃO `faulthandler.dump_traceback_later` (#194): a thread em C dele
18+ percorre os frames das outras threads SEM o GIL, enquanto elas executam Python.
19+ No macOS arm64 isso trava a própria thread em C dentro do dump (reproduzido em
20+ CI: thread principal em recursão com exceções no instante do dump) e o
21+ `cancel_dump_traceback_later` seguinte - que todo batimento e o desarme
22+ chamavam - espera por ela para sempre, pendurando o processo inteiro. O dump
23+ síncrono roda COM o GIL: nenhuma outra thread mexe em frames enquanto ele lê.
24+ O que se perde é o retrato de um travamento que segure o GIL em código C; esse
25+ caso segue coberto pelo encerramento do pai, só sem as pilhas.
1626
1727O dump vai para `hang.log` NA PASTA DA REUNIÃO, nunca para o process.log: o
1828tamanho do process.log é um dos sinais de vida que o pai sonda, e escrever nele
19- adiaria (ou anularia, com `repeat` ) o encerramento do filho travado. Com
20- `repeat=True` saem dois retratos (8 e 16 min parado ) antes do pai agir: pilhas
21- iguais = travado; diferentes = lentidão extrema. O arquivo só fica na pasta se
22- houve dump; sem dump ele é removido no desarme.
29+ adiaria (ou anularia) o encerramento do filho travado. Parado de vez, saem
30+ retratos a cada `idle_s` (8 e 16 min) antes do pai agir: pilhas iguais =
31+ travado; diferentes = lentidão extrema. O arquivo só fica na pasta se houve
32+ dump; sem dump ele é removido no desarme.
2333"""
2434
2535from __future__ import annotations
3747
3848IDLE_S = 8 * 60 # sem batimento por tanto tempo = despeja as pilhas
3949HANG_LOG = "hang.log" # na pasta da reunião (o do app, em logs/, é o da GUI)
40- _MIN_REARM_S = 1.0 # cada rearme cria uma thread em C; batidas mais miúdas são coalescidas
50+ _POLL_S = 1.0 # cadência máxima da conferência (mais fina com idle_s curto)
51+ _JOIN_S = 2.0 # quanto o desarme espera a thread da sentinela encerrar
4152
4253
4354class _State :
4455 __slots__ = ("folder" , "path" , "file" , "idle_s" , "header_end" , "suspended" ,
45- "last_arm " , "prev_stdout" )
56+ "last_beat " , "prev_stdout" , "stop" , "thread" , "retratos " )
4657
4758 def __init__ (self , folder : Path , path : Path , file , idle_s : float , header_end : int ,
4859 prev_stdout ) -> None :
@@ -52,8 +63,11 @@ def __init__(self, folder: Path, path: Path, file, idle_s: float, header_end: in
5263 self .idle_s = idle_s
5364 self .header_end = header_end
5465 self .suspended = False
55- self .last_arm = 0.0
66+ self .last_beat = time . monotonic ()
5667 self .prev_stdout = prev_stdout
68+ self .stop = threading .Event ()
69+ self .thread : threading .Thread | None = None
70+ self .retratos = 0
5771
5872
5973_lock = threading .Lock ()
@@ -77,9 +91,33 @@ def __getattr__(self, name):
7791 return getattr (self ._s , name )
7892
7993
80- def _schedule (st : _State ) -> None :
81- faulthandler .dump_traceback_later (st .idle_s , repeat = True , file = st .file , exit = False )
82- st .last_arm = time .monotonic ()
94+ def _dump_locked (st : _State , parado_s : float ) -> None :
95+ """(thread da sentinela, com _lock e o GIL) Cabeçalho + pilhas de todas as threads."""
96+ st .retratos += 1
97+ limiar = f"{ parado_s / 60 :.0f} min" if parado_s >= 60 else f"{ parado_s :.1f} s"
98+ st .file .write (f"==== { datetime .now ():%d/%m/%Y %H:%M:%S} · { limiar } sem progresso "
99+ f"(retrato { st .retratos } ) ====\n " )
100+ st .file .flush () # o faulthandler escreve direto no fd: o cabeçalho tem que ir antes
101+ faulthandler .dump_traceback (file = st .file , all_threads = True )
102+ st .file .flush ()
103+
104+
105+ def _monitor (st : _State ) -> None :
106+ poll = max (0.02 , min (_POLL_S , st .idle_s / 4 ))
107+ while not st .stop .wait (poll ):
108+ with _lock :
109+ if _state is not st :
110+ return
111+ if st .suspended :
112+ continue
113+ parado = time .monotonic () - st .last_beat
114+ if parado < st .idle_s :
115+ continue
116+ try :
117+ _dump_locked (st , parado )
118+ except Exception :
119+ log .debug ("sentinela: dump falhou" , exc_info = True )
120+ st .last_beat = time .monotonic () # próximo retrato só após mais idle_s parado
83121
84122
85123def arm (folder , idle_s : float | None = None ) -> bool :
@@ -93,7 +131,7 @@ def arm(folder, idle_s: float | None = None) -> bool:
93131 with _lock :
94132 if _state is not None :
95133 if _state .folder == folder :
96- _beat_locked ( _state )
134+ _state . last_beat = time . monotonic ( )
97135 return True
98136 _disarm_locked (None )
99137 try :
@@ -110,7 +148,9 @@ def arm(folder, idle_s: float | None = None) -> bool:
110148 if prev_stdout is not None and not isinstance (prev_stdout , _BeatingStream ):
111149 sys .stdout = _BeatingStream (prev_stdout )
112150 st = _State (folder , path , f , float (idle_s ), header_end , prev_stdout )
113- _schedule (st )
151+ st .thread = threading .Thread (target = _monitor , args = (st ,), daemon = True ,
152+ name = "scriba-idlewatch" )
153+ st .thread .start ()
114154 except Exception :
115155 f .close ()
116156 raise
@@ -121,51 +161,28 @@ def arm(folder, idle_s: float | None = None) -> bool:
121161 return True
122162
123163
124- def _beat_locked (st : _State ) -> None :
125- if st .suspended :
126- return
127- if time .monotonic () - st .last_arm < _MIN_REARM_S :
128- return
129- try :
130- _schedule (st )
131- except Exception :
132- log .debug ("sentinela: rearme falhou" , exc_info = True )
133-
134-
135164def beat () -> None :
136- """Sinal de vida (thread-safe, barato quando bate mais de uma vez por segundo )."""
165+ """Sinal de vida (thread-safe e barato: um timestamp, sem lock nem thread nova )."""
137166 st = _state
138- if st is None :
139- return
140- with _lock :
141- if _state is st :
142- _beat_locked (st )
167+ if st is not None :
168+ st .last_beat = time .monotonic ()
143169
144170
145171def suspend () -> None :
146172 """Pausa a sentinela: espera legítima sem CPU nem log (o resumo via rede/CLI)."""
147173 with _lock :
148174 st = _state
149- if st is None or st .suspended :
150- return
151- st .suspended = True
152- try :
153- faulthandler .cancel_dump_traceback_later ()
154- except Exception :
155- pass
175+ if st is not None :
176+ st .suspended = True
156177
157178
158179def resume () -> None :
159- """Volta a vigiar depois de `suspend()`."""
180+ """Volta a vigiar depois de `suspend()`; o prazo conta a partir daqui ."""
160181 with _lock :
161182 st = _state
162- if st is None or not st .suspended :
163- return
164- st .suspended = False
165- try :
166- _schedule (st )
167- except Exception :
168- log .debug ("sentinela: rearme após suspensão falhou" , exc_info = True )
183+ if st is not None and st .suspended :
184+ st .suspended = False
185+ st .last_beat = time .monotonic ()
169186
170187
171188def dumped () -> bool :
@@ -180,16 +197,13 @@ def dumped() -> bool:
180197 return False
181198
182199
183- def _disarm_locked (note : str | None ) -> None :
200+ def _disarm_locked (note : str | None ) -> _State | None :
184201 global _state
185202 st = _state
186203 if st is None :
187- return
204+ return None
188205 _state = None
189- try :
190- faulthandler .cancel_dump_traceback_later ()
191- except Exception :
192- pass
206+ st .stop .set ()
193207 if isinstance (sys .stdout , _BeatingStream ):
194208 sys .stdout = st .prev_stdout
195209 houve_dump = False
@@ -209,11 +223,15 @@ def _disarm_locked(note: str | None) -> None:
209223 st .path .unlink (missing_ok = True )
210224 except OSError :
211225 pass
226+ return st
212227
213228
214229def disarm (note : str | None = "o processo seguiu depois do dump: lentidão extrema, "
215230 "não travamento (o pai não precisou encerrá-lo)" ) -> None :
216231 """Desliga a sentinela. Sem dump, o hang.log é removido; com dump, `note`
217232 é anexada para o leitor saber que o processo NÃO morreu ali."""
218233 with _lock :
219- _disarm_locked (note )
234+ st = _disarm_locked (note )
235+ # o join fica FORA do _lock: a thread precisa dele para perceber o desarme
236+ if st is not None and st .thread is not None and st .thread is not threading .current_thread ():
237+ st .thread .join (_JOIN_S )
0 commit comments