-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmain.rs
More file actions
1274 lines (1183 loc) · 56.8 KB
/
Copy pathmain.rs
File metadata and controls
1274 lines (1183 loc) · 56.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
//! Entry point for the `peel` CLI.
//!
//! Parses the command-line via [`peel::cli::Cli`], constructs a
//! [`peel::coordinator::RunArgs`], and runs the pipeline.
//!
//! Progress is rendered by a [`peel::progress`] renderer thread spawned
//! at the binary boundary: a multi-line ANSI block on a TTY (PLAN_v2.md
//! §6) or one structured `tracing::info!` event per tick when stderr is
//! not a terminal. The renderer reads a shared [`peel::progress::ProgressState`]
//! that the coordinator, download workers, and extractor update directly.
//!
//! Errors at the binary boundary are wrapped via [`anyhow`] per
//! `internal/ENGINEERING_STANDARDS.md` §3.2.
#![warn(unused, clippy::all)]
fn main() -> anyhow::Result<()> {
app_main::main()
}
// The body of the binary, kept in a private module so the file's
// top-level `fn main` is a single thin shim. `PLAN_v3_windows.md` §5
// makes `app_main` cross-platform — the only platform-specific bits
// are the signal-handler installation and the async-signal-safe
// stderr-write / process-exit helpers, all behind `cfg` inside
// `mod signals` below.
mod app_main {
use std::error::Error as StdError;
use std::io::IsTerminal;
use std::ptr;
use std::sync::atomic::{AtomicBool, AtomicI32, AtomicPtr, Ordering};
use std::sync::Arc;
use std::thread;
use std::time::{Duration, Instant};
use anyhow::{Context, Result};
use clap::Parser;
use peel::cli::{http_version_banner, Cli, Dispatch};
use peel::coordinator::local::{run as run_local, LocalRunArgs};
use peel::coordinator::{run, CoordinatorError, ProgressEvent, ProgressFn, RunArgs, RunStats};
use peel::decode::DecoderRegistry;
use peel::download::{SchedulerError, WorkerError};
use peel::encryption::EncryptionError;
use peel::progress::{format_eta, spawn_renderer, LogRenderer, ProgressState, TtyRenderer};
/// Synthetic signal numbers we report through the `[abort]` line
/// and the conventional `128 + signum` exit code. On Unix these
/// are the real signal values (`SIGINT = 2`, `SIGTERM = 15`,
/// `SIGHUP = 1`); the Windows console-ctrl handler translates the
/// platform-native `CTRL_*_EVENT` values into the same set so
/// shell scripts see the familiar "130" / "143" / "129" exit
/// codes (`PLAN_v3_windows.md` §5).
const SIGHUP: i32 = 1;
const SIGINT: i32 = 2;
const SIGQUIT_OR_BREAK: i32 = 3;
const SIGTERM: i32 = 15;
/// First-signal notice (TTY variant). The leading `\r\x1b[K` returns
/// to column 0 and clears the current line so the message lands cleanly
/// even if the TTY progress renderer has just drawn there; the trailing
/// newline pushes the renderer's next tick down by one row instead of
/// overwriting our text in place.
const SHUTDOWN_GRACEFUL_MSG_TTY: &[u8] =
b"\r\x1b[KShutdown request received, performing graceful shutdown...\n";
/// First-signal notice (non-TTY variant). No ANSI escapes — kubelet's
/// log capture stores them verbatim and downstream log viewers (Loki,
/// Stackdriver, plain `kubectl logs -f`) display them as garbage. This
/// is the form an operator sees in `kubectl logs` after `kubectl delete
/// pod`.
const SHUTDOWN_GRACEFUL_MSG_PLAIN: &[u8] =
b"Shutdown request received, performing graceful shutdown...\n";
/// Second-signal notice (TTY variant). Printed immediately before `_exit`.
const FORCEFUL_MSG_TTY: &[u8] =
b"\r\x1b[KSecond shutdown signal received, forcing immediate exit\n";
/// Second-signal notice (non-TTY variant).
const FORCEFUL_MSG_PLAIN: &[u8] = b"Second shutdown signal received, forcing immediate exit\n";
/// Watchdog-fired notice (TTY variant). Printed immediately before the
/// watchdog thread `_exit`s once `GRACEFUL_DEADLINE` elapses without
/// `run` returning.
const WATCHDOG_MSG_TTY: &[u8] =
b"\r\x1b[KGraceful shutdown deadline elapsed, forcing immediate exit\n";
/// Watchdog-fired notice (non-TTY variant).
const WATCHDOG_MSG_PLAIN: &[u8] =
b"Graceful shutdown deadline elapsed, forcing immediate exit\n";
/// Hard upper bound on the wait between the first shutdown signal and
/// the process exiting. Belt-and-suspenders for any kill-switch poll
/// site we missed: even if the run is fully stuck and never observes
/// the flag, the watchdog `_exit`s at the deadline. 30 s is well under
/// the typical Kubernetes `terminationGracePeriodSeconds` (60–120 s),
/// so a checkpoint-during-graceful path that *is* making progress
/// still has time to land. Override via
/// `PEEL_GRACEFUL_DEADLINE_SECS` (positive integer).
const DEFAULT_GRACEFUL_DEADLINE: Duration = Duration::from_secs(30);
/// Pointer to the kill-switch [`AtomicBool`] handed to
/// [`peel::coordinator::run`] via [`peel::coordinator::RunArgs::kill_switch`].
/// The signal handler reads this with one async-signal-safe atomic
/// load and stores `true` into the pointee; `main` keeps the owning
/// [`Arc`] alive until process exit so the dereference is always
/// valid.
static SHUTDOWN_PTR: AtomicPtr<AtomicBool> = AtomicPtr::new(ptr::null_mut());
/// Set by `main` before installing handlers so the signal handler can
/// pick the TTY-vs-non-TTY message variant. Reading an `AtomicBool`
/// is async-signal-safe.
static STDERR_IS_TTY: AtomicBool = AtomicBool::new(false);
/// Number of shutdown signals delivered so far. The first delivery
/// flips the kill switch (graceful: finish or skip the current
/// checkpoint, then return [`CoordinatorError::Aborted`]); a second
/// delivery short-circuits to `_exit(128 + signum)` so an unresponsive
/// graceful path can always be escaped.
static SIGNAL_COUNT: AtomicI32 = AtomicI32::new(0);
/// Signal number of the *first* shutdown signal we received. `main`
/// reads it after [`run`] returns [`CoordinatorError::Aborted`] so the
/// process exit status follows the conventional `128 + signum` shape
/// (130 for SIGINT, 143 for SIGTERM).
static FIRST_SIGNAL: AtomicI32 = AtomicI32::new(0);
/// Shared body of the shutdown handler. Called from the Unix
/// signal handler (`extern "C" fn(i32)`) and from the Windows
/// console-ctrl handler (`extern "system" fn(u32) -> BOOL`), both
/// defined in `signals::imp`. **Async-signal-safe operations
/// only:** atomic loads/stores, raw stderr writes of static byte
/// slices, and (on the second signal) `_exit` / `ExitProcess`.
/// No allocation, no formatting, no locking, no `Arc` ref-count
/// traffic.
fn dispatch_shutdown(sig: i32) {
// The previous count tells us whether this is the first delivery
// (count == 0 → graceful) or a follow-up (count >= 1 → forceful).
// Doing this `fetch_add` first means a second signal that arrives
// mid-handler still observes "this is the second one" and takes
// the immediate-exit branch.
if SIGNAL_COUNT.fetch_add(1, Ordering::AcqRel) == 0 {
// First delivery: graceful path. Record the signal number so
// `main` can pick the conventional `128 + signum` exit code,
// flip the kill switch the coordinator polls between
// checkpoints, and emit a one-line notice so an interactive
// operator knows their Ctrl-C registered.
FIRST_SIGNAL.store(sig, Ordering::Release);
let ptr = SHUTDOWN_PTR.load(Ordering::Acquire);
if !ptr.is_null() {
// SAFETY: `SHUTDOWN_PTR` is set in
// `install_signal_handlers` from `Arc::as_ptr` on a
// heap `AtomicBool`. `main` holds the owning `Arc`
// until the process exits (either via normal return
// or the immediate-exit below), so the pointee
// outlives every signal delivery. `AtomicBool::store`
// is async-signal-safe.
unsafe { (*ptr).store(true, Ordering::Release) };
}
let msg: &[u8] = if STDERR_IS_TTY.load(Ordering::Acquire) {
SHUTDOWN_GRACEFUL_MSG_TTY
} else {
SHUTDOWN_GRACEFUL_MSG_PLAIN
};
signals::write_stderr_static(msg);
} else {
// Second (or later) delivery: drop the polite path. Best-effort
// notice, then immediate exit.
let msg: &[u8] = if STDERR_IS_TTY.load(Ordering::Acquire) {
FORCEFUL_MSG_TTY
} else {
FORCEFUL_MSG_PLAIN
};
signals::write_stderr_static(msg);
signals::proc_exit(128 + sig);
}
}
/// Install platform-specific signal handlers that flip `kill` on
/// first delivery and call `signals::proc_exit(128 + signum)` on
/// the second.
///
/// Must be called before any thread that holds the `Arc` is
/// spawned. `main` calls it from the single-threaded prelude, so
/// the `SHUTDOWN_PTR` store is happens-before any subsequent
/// thread spawn and the handler observes a valid pointer from
/// the first delivery.
fn install_signal_handlers(kill: &Arc<AtomicBool>) -> Result<()> {
SHUTDOWN_PTR.store(Arc::as_ptr(kill) as *mut AtomicBool, Ordering::Release);
signals::imp::install_platform_handlers()
}
/// Human-readable name for the synthetic signal number reported
/// through the `[abort]` line. The Unix path passes the real
/// signal value; the Windows path maps `CTRL_*_EVENT` onto these
/// same numbers so the diagnostic vocabulary is portable.
fn signal_name(sig: i32) -> &'static str {
match sig {
SIGHUP => "SIGHUP",
SIGINT => "SIGINT",
SIGQUIT_OR_BREAK => {
// Unix: SIGQUIT (3). Windows: synthetic mapping of
// `CTRL_BREAK_EVENT`. Same exit-code convention.
if cfg!(windows) {
"CTRL_BREAK"
} else {
"SIGQUIT"
}
}
SIGTERM => "SIGTERM",
_ => "signal",
}
}
mod signals {
use std::sync::atomic::Ordering;
// Re-exports so the cfg-internal impl can reach the parent's
// dispatch entry point. The inner `imp` module is the only
// platform-specific bit; everything else in this module is
// portable.
use super::dispatch_shutdown;
use super::{
FIRST_SIGNAL, SIGNAL_COUNT, STDERR_IS_TTY, WATCHDOG_MSG_PLAIN, WATCHDOG_MSG_TTY,
};
/// Best-effort async-signal-safe write of a `'static` byte
/// slice to stderr. The return value (bytes written, if
/// any) is discarded because there is no useful recovery
/// from a partial / failed stderr write inside a signal
/// handler.
pub(super) fn write_stderr_static(bytes: &[u8]) {
imp::write_stderr_static(bytes)
}
/// Immediate process exit with `code`. On Unix this is
/// `_exit(2)`; on Windows it is `ExitProcess`. Both are the
/// textbook "exit from a signal / console-ctrl handler"
/// primitive on their platform.
pub(super) fn proc_exit(code: i32) -> ! {
imp::proc_exit(code)
}
/// Fire the watchdog's "deadline elapsed" exit path. The
/// watchdog runs in a regular thread (not a signal handler),
/// but we route through the same async-signal-safe write +
/// exit primitives so the abort line is byte-for-byte
/// identical to the in-handler messages.
pub(super) fn watchdog_force_exit() -> ! {
let msg: &[u8] = if STDERR_IS_TTY.load(Ordering::Acquire) {
WATCHDOG_MSG_TTY
} else {
WATCHDOG_MSG_PLAIN
};
write_stderr_static(msg);
let sig = FIRST_SIGNAL.load(Ordering::Acquire);
let _ = SIGNAL_COUNT.fetch_add(1, Ordering::AcqRel);
proc_exit(128 + sig)
}
#[cfg(unix)]
pub(super) mod imp {
//! Unix signal-handler installation via `signal(2)`.
//!
//! POSIX deprecates `signal(2)` in favor of
//! `sigaction(2)`, but `struct sigaction`'s field order,
//! `sigset_t` size, and `sa_restorer` presence vary
//! across glibc / musl / macOS — a portable FFI
//! declaration would need three different `#[repr(C)]`
//! shapes plus `cfg` arms. `signal()` has a uniform
//! signature, and on every libc we target it installs a
//! persistent BSD-semantic handler with `SA_RESTART`.
//! That is exactly what we want here: in-flight
//! `read`/`write` syscalls that get interrupted simply
//! retry instead of returning `EINTR`, so the
//! download/extract loop is unaffected up to the next
//! checkpoint where the kill switch is observed.
use anyhow::{Context, Result};
use super::super::{SIGINT, SIGTERM};
const SIG_ERR: usize = usize::MAX;
const STDERR_FD: i32 = 2;
extern "C" {
fn signal(signum: i32, handler: extern "C" fn(i32)) -> usize;
fn _exit(status: i32) -> !;
fn write(fd: i32, buf: *const u8, count: usize) -> isize;
}
extern "C" fn handler(sig: i32) {
super::dispatch_shutdown(sig);
}
pub(in super::super) fn install_platform_handlers() -> Result<()> {
for sig in [SIGINT, SIGTERM] {
// SAFETY: `signal(2)` takes a signal number and a
// function pointer with `extern "C" fn(i32)` ABI,
// which matches `handler`. The handler only
// performs async-signal-safe work.
let prev = unsafe { signal(sig, handler) };
if prev == SIG_ERR {
return Err(std::io::Error::last_os_error())
.with_context(|| format!("installing handler for signal {sig}"));
}
}
Ok(())
}
pub(super) fn write_stderr_static(bytes: &[u8]) {
// SAFETY: `write(2)` with a `'static` byte slice is
// async-signal-safe. We discard the return value.
unsafe { write(STDERR_FD, bytes.as_ptr(), bytes.len()) };
}
pub(super) fn proc_exit(code: i32) -> ! {
// SAFETY: `_exit(2)` is the textbook async-signal-safe
// exit call; calling it from a signal handler is its
// intended use.
unsafe { _exit(code) }
}
}
#[cfg(windows)]
pub(super) mod imp {
//! Windows console-ctrl handler installation via
//! `SetConsoleCtrlHandler` (`PLAN_v3_windows.md` §5).
//!
//! Windows does not have Unix-style asynchronous signals
//! delivered to the main thread; instead the OS spawns a
//! dedicated worker thread to call our registered handler.
//! Async-signal-safety is therefore not strictly required
//! (the handler is not interrupting arbitrary code), but
//! we keep the no-allocation / no-formatting discipline so
//! the body is interchangeable with the Unix one.
//!
//! Specifically: `CTRL_CLOSE_EVENT` (the console window's
//! X button) gives the process ~5 seconds before Windows
//! unconditionally terminates it. The graceful watchdog's
//! default deadline is 30 s; on `CTRL_CLOSE_EVENT` the
//! watchdog has no chance of firing first, so we rely on
//! the kill switch being observed quickly enough that the
//! checkpoint write completes before the OS pulls the
//! plug. The shutdown-message bytes still land via
//! `WriteFile`.
use anyhow::{Context, Result};
use windows_sys::Win32::Foundation::BOOL;
use windows_sys::Win32::Storage::FileSystem::WriteFile;
use windows_sys::Win32::System::Console::{
GetStdHandle, SetConsoleCtrlHandler, CTRL_BREAK_EVENT, CTRL_CLOSE_EVENT,
CTRL_C_EVENT, CTRL_LOGOFF_EVENT, CTRL_SHUTDOWN_EVENT, STD_ERROR_HANDLE,
};
use windows_sys::Win32::System::Threading::ExitProcess;
use super::super::{SIGHUP, SIGINT, SIGQUIT_OR_BREAK, SIGTERM};
extern "system" fn handler(ctrl_type: u32) -> BOOL {
// Map Windows console-ctrl event codes to the same
// synthetic signal numbers the Unix path uses so the
// `[abort]` line and the `128 + signum` exit code are
// portable. `CTRL_CLOSE_EVENT` (window-close X) and
// `CTRL_LOGOFF_EVENT` / `CTRL_SHUTDOWN_EVENT` (user
// session ending) all get the SIGHUP convention used
// for "controlling terminal disappeared".
let sig = match ctrl_type {
CTRL_C_EVENT => SIGINT,
CTRL_BREAK_EVENT => SIGQUIT_OR_BREAK,
CTRL_CLOSE_EVENT | CTRL_LOGOFF_EVENT | CTRL_SHUTDOWN_EVENT => SIGHUP,
_ => return 0, // FALSE: not handled; let the next handler in the chain decide
};
super::dispatch_shutdown(sig);
if matches!(
ctrl_type,
CTRL_CLOSE_EVENT | CTRL_LOGOFF_EVENT | CTRL_SHUTDOWN_EVENT
) {
// Windows hard-terminates the process ~5 s after
// we return TRUE on a close/logoff/shutdown.
// We're already at the second-signal threshold
// (the OS won't give us a chance for a "graceful
// then forceful" two-step), so finish the
// forceful path inline so the abort line lands
// before the OS kills us.
let _ = SIGTERM; // keep the import live regardless of cfg arm
proc_exit(128 + sig);
}
1 // TRUE: handled
}
pub(in super::super) fn install_platform_handlers() -> Result<()> {
// SAFETY: `SetConsoleCtrlHandler(Some(handler), TRUE)`
// takes an `extern "system" fn(u32) -> BOOL` pointer
// with no extra-state contract beyond async-thread
// safety, which `handler` satisfies. Returns
// non-zero on success.
let rc = unsafe { SetConsoleCtrlHandler(Some(handler), 1) };
if rc == 0 {
return Err(std::io::Error::last_os_error())
.context("installing Windows console-ctrl handler");
}
Ok(())
}
pub(super) fn write_stderr_static(bytes: &[u8]) {
// SAFETY: `GetStdHandle(STD_ERROR_HANDLE)` returns
// the process-wide stderr handle; the value is valid
// until process exit. `WriteFile` against a console
// or file handle with a `'static` buffer and the
// matching length is safe to call from any thread,
// including the console-ctrl handler thread.
let h = unsafe { GetStdHandle(STD_ERROR_HANDLE) };
if h.is_null() {
return;
}
let len = u32::try_from(bytes.len()).unwrap_or(u32::MAX);
let mut written: u32 = 0;
unsafe {
WriteFile(h, bytes.as_ptr(), len, &mut written, std::ptr::null_mut());
}
}
pub(super) fn proc_exit(code: i32) -> ! {
// `ExitProcess` is the Windows analog of `_exit(2)`:
// a clean, immediate process exit that runs no
// destructors. `code` is `u32` on Windows; the
// conventional `128 + signum` we pass is always
// positive and fits in `i32`, so the
// bit-equivalent `as u32` cast is lossless.
// (`i32::cast_unsigned` would be cleaner but is
// 1.87+ and our MSRV is 1.85.)
#[allow(clippy::cast_sign_loss)]
let exit_code = code as u32;
// SAFETY: terminates the process; non-returning.
unsafe { ExitProcess(exit_code) }
}
}
}
/// Read `PEEL_GRACEFUL_DEADLINE_SECS` (positive integer) and fall back
/// to [`DEFAULT_GRACEFUL_DEADLINE`] otherwise.
fn graceful_deadline_from_env() -> Duration {
std::env::var("PEEL_GRACEFUL_DEADLINE_SECS")
.ok()
.and_then(|s| s.trim().parse::<u64>().ok())
.filter(|n| *n > 0)
.map(Duration::from_secs)
.unwrap_or(DEFAULT_GRACEFUL_DEADLINE)
}
/// Spawn the graceful-deadline watchdog (`PLAN_responsiveness.md`
/// §2.4).
///
/// The thread polls [`SIGNAL_COUNT`] every 100 ms while the run is
/// healthy. Once a shutdown signal lands, it sleeps `deadline` and —
/// if `cleanup_done` is still `false` — emits an `[abort]` line and
/// `_exit`s the process. `main` flips `cleanup_done` immediately
/// before returning so a clean exit before the deadline cancels the
/// watchdog. The thread is detached: there is no join path.
///
/// This guards against any kill-switch poll site we missed (or that
/// hangs in non-cooperative work like a CPU-bound third-party
/// codec). Pods that take >30 s to terminate are themselves a
/// production problem in Kubernetes, so capping the graceful path is
/// healthy.
fn install_graceful_watchdog(deadline: Duration, cleanup_done: Arc<AtomicBool>) {
let _ = std::thread::Builder::new()
.name("peel-graceful-watchdog".into())
.spawn(move || {
// Phase 1: idle until either cleanup signals "we're done"
// (no signal arrived; the run finished cleanly) or a
// shutdown signal lands.
loop {
std::thread::sleep(Duration::from_millis(100));
if cleanup_done.load(Ordering::Acquire) {
return;
}
if SIGNAL_COUNT.load(Ordering::Acquire) > 0 {
break;
}
}
// Phase 2: graceful deadline. The signal handler has
// already flipped the kill switch and printed the
// "initiating graceful shutdown" notice; we wait the
// configured deadline for the run to wind down.
let started = std::time::Instant::now();
while started.elapsed() < deadline {
std::thread::sleep(Duration::from_millis(100));
if cleanup_done.load(Ordering::Acquire) {
return;
}
}
// Phase 3: deadline elapsed. The run is genuinely
// stuck. The helper emits the same byte-for-byte
// abort line the in-handler path would, then
// unconditionally exits via the platform's immediate-
// exit primitive (`_exit(2)` on Unix, `ExitProcess`
// on Windows).
signals::watchdog_force_exit();
})
.expect("spawn graceful watchdog");
}
/// Walk an [`anyhow::Error`]'s source chain looking for an
/// [`EncryptionError`] variant that should map to exit code 4.
///
/// Returns `true` for [`EncryptionError::PasswordIncorrect`] and
/// [`EncryptionError::PasswordMissing`] — both indicate the user
/// can retry with a different / supplied password and we want
/// scripts to distinguish that from the generic "extraction
/// failed" code 1.
///
/// Implements the exit-code routing described in
/// `internal/PLAN_archive_encryption.md` §6.
fn password_exit_code_required(err: &anyhow::Error) -> bool {
let mut current: &(dyn StdError + 'static) = err.as_ref();
loop {
if let Some(enc) = current.downcast_ref::<EncryptionError>() {
return matches!(
enc,
EncryptionError::PasswordIncorrect | EncryptionError::PasswordMissing,
);
}
match current.source() {
Some(next) => current = next,
None => return false,
}
}
}
pub fn main() -> Result<()> {
let cli = Cli::parse();
// Pick the progress mode from whether stderr is a real
// terminal AND can accept ANSI escapes. The TTY path uses
// hand-rolled ANSI on stderr; the non-TTY path emits
// `tracing::info!` events that the subscriber below routes
// back to stderr in human-readable form. On Windows 10 1607+
// `tty_supports_ansi` enables virtual-terminal processing on
// the console handle as a side effect; on pre-1607 Windows
// (or any platform where `SetConsoleMode` rejects the
// VT bit) the helper returns `false` and we drop to the
// log renderer rather than emitting literal escape codes
// (`PLAN_v3_windows.md` §7).
let stderr_is_tty = std::io::stderr().is_terminal() && peel::progress::tty_supports_ansi();
// Publish the TTY status so the signal handler picks the right
// message variant — kubelet log capture stores ANSI escapes
// verbatim, so the non-TTY path needs a clean message.
STDERR_IS_TTY.store(stderr_is_tty, Ordering::Release);
init_tracing(stderr_is_tty);
// Install SIGINT/SIGTERM handlers as early as possible — before
// any worker threads exist — so a signal arriving during setup is
// observed by the kill switch the coordinator polls between
// checkpoints. As PID 1 in a Kubernetes pod the kernel applies no
// default disposition for these signals, so without this the
// process would silently ignore SIGTERM and only exit on the
// kubelet's escalation to SIGKILL after the grace period elapses.
let kill_switch = Arc::new(AtomicBool::new(false));
install_signal_handlers(&kill_switch).context("installing signal handlers")?;
// §2.4: arm the graceful watchdog. The thread is detached and
// cancels itself when `cleanup_done` flips to `true` (set right
// before `main` returns); if the deadline elapses first, it
// `_exit`s with the conventional `128 + signum` code so an
// unresponsive graceful path can't hold the pod hostage.
let cleanup_done = Arc::new(AtomicBool::new(false));
install_graceful_watchdog(graceful_deadline_from_env(), Arc::clone(&cleanup_done));
// Capture the http_version label before consuming `cli`. Both
// banners (http_version and io_backend) are printed below as plain
// stderr scrollback ABOVE the TTY renderer's redraw region — the
// renderer's cursor-up math can't reach into scrollback, so the
// banners are immune to corruption from later redraws. On non-TTY
// we leave them to the `tracing::info!` calls inside
// `into_run_args` and `select_backend` (the subscriber is at INFO
// level there).
let http_banner = http_version_banner(cli.http_version.into());
let dispatch = cli.into_dispatch().context("parsing CLI arguments")?;
// Local-file mode (`internal/old/PLAN_local_file_extract.md`) skips
// every HTTP-side knob — no scheduler retries, no
// multi-attempt rebuild, no mirror state. Handle it before
// setting up the HTTP-flavored renderer scaffolding.
if let Dispatch::Local { args: local_args } = dispatch {
return run_local_dispatch(*local_args, stderr_is_tty, &kill_switch, cleanup_done);
}
let mut args = match dispatch {
Dispatch::Http(args) => *args,
Dispatch::Local { .. } => unreachable!("filtered above"),
};
// Resolve the IO backend in main (rather than letting
// `coordinator::run` do it) so we can print the `io_backend=…`
// label as scrollback BEFORE spawning the renderer. Coordinator
// accepts the resolved backend via `RunArgs::io_backend` and
// skips its own resolution step.
let (io_backend, io_backend_label) =
peel::io_backend::select_backend(args.config.io_backend, args.config.workers)
.context("resolving the IO backend")?;
args.io_backend = Some(io_backend);
if stderr_is_tty {
eprintln!("{http_banner}");
eprintln!("{io_backend_label}");
}
let state = ProgressState::new();
// Capture the cloneable bits of `args` so the outer-loop retry path
// can rebuild a fresh `RunArgs` per attempt. `RunArgs` itself is
// not `Clone` (the boxed `ProgressFn` and the `Arc<dyn IoBackend>`
// are not trivially cloneable as a unit), but every field we need
// is independently cloneable; we just have to assemble them.
let url = args.url.clone();
let additional_urls = args.additional_urls.clone();
let output = args.output.clone();
let coord_config = args.config.clone();
let client = args.client.clone();
// INVARIANT: we set `args.io_backend = Some(io_backend)` above.
let io_backend_arc = args.io_backend.clone().expect("io_backend was set above");
let make_args = || RunArgs {
url: url.clone(),
additional_urls: additional_urls.clone(),
output: output.clone(),
config: coord_config.clone(),
client: client.clone(),
registry: DecoderRegistry::with_defaults(),
progress: Some(make_event_callback(stderr_is_tty)),
progress_state: Some(Arc::clone(&state)),
kill_switch: Some(Arc::clone(&kill_switch)),
io_backend: Some(Arc::clone(&io_backend_arc)),
};
args.progress_state = Some(Arc::clone(&state));
args.progress = Some(make_event_callback(stderr_is_tty));
args.kill_switch = Some(Arc::clone(&kill_switch));
// Spawn the renderer thread. TTY mode redraws three lines in place
// every 100 ms; non-TTY mode emits one structured log line every
// 2 s so a piped log file remains readable.
let render_handle = if stderr_is_tty {
spawn_renderer(
Arc::clone(&state),
TtyRenderer::new(std::io::stderr()),
Duration::from_millis(100),
Some(Arc::clone(&kill_switch)),
)
.context("spawning the TTY progress renderer")?
} else {
spawn_renderer(
Arc::clone(&state),
LogRenderer::new(),
Duration::from_secs(2),
Some(Arc::clone(&kill_switch)),
)
.context("spawning the log progress renderer")?
};
let result = run_with_outer_retry(args, &make_args, &state, &kill_switch, stderr_is_tty);
// Tell the renderer to stop, regardless of whether `run` succeeded
// or errored, so we can join it before exiting `main`.
state.mark_done();
let _ = render_handle.join();
// §2.4: stand down the graceful watchdog now that the run is fully
// wrapped up — including the renderer thread join. If a SIGTERM
// arrives between this store and process exit it has nothing left
// to interrupt; the watchdog is no longer needed.
cleanup_done.store(true, Ordering::Release);
let stats = match result {
Ok(stats) => stats,
Err(CoordinatorError::Aborted {
checkpoints_written,
}) => {
// Graceful abort triggered by a SIGINT/SIGTERM landing in
// `shutdown_handler`. The most recent `.peel.part` and
// `.peel.ckpt` are durable on disk; the next invocation
// resumes from there. Exit with the conventional
// `128 + signum` status (130 for SIGINT, 143 for SIGTERM)
// so kubelet / shells see the expected code.
let sig = FIRST_SIGNAL.load(Ordering::Acquire);
eprintln!(
"[abort] {} received, exited after {} checkpoints \
(.peel.part / .peel.ckpt left for resume)",
signal_name(sig),
checkpoints_written,
);
std::process::exit(128 + sig);
}
Err(other) => {
let wrapped = anyhow::Error::from(other).context("running peel");
// §6 of `internal/PLAN_archive_encryption.md`: surface a
// wrong-password / missing-password failure as exit code
// 4 so scripts can distinguish it from the generic
// "extraction failed" code 1. The error chain may wrap
// the `EncryptionError` several layers deep
// (CoordinatorError → ZipPipelineError → ZipError →
// EncryptionError, etc.), so we walk the
// `Error::source` chain.
if password_exit_code_required(&wrapped) {
eprintln!("[error] {wrapped:#}");
cleanup_done.store(true, Ordering::Release);
std::process::exit(4);
}
return Err(wrapped);
}
};
eprintln!(
"[done] {} bytes downloaded, {} bytes extracted in {:.2}s{}",
stats.download.bytes_downloaded,
stats.extraction.bytes_out,
stats.elapsed.as_secs_f64(),
if stats.resumed { " (resumed)" } else { "" },
);
eprintln!(
"[stats] download chunks={} retries={} mode={:?}",
stats.download.chunks_completed, stats.download.retries, stats.download.mode,
);
eprintln!(
"[stats] extract bytes_in={} bytes_out={} bytes_punched={} \
frames={} checkpoints={}",
stats.extraction.bytes_in,
stats.extraction.bytes_out,
stats.extraction.bytes_punched,
stats.extraction.frame_boundaries_observed,
stats.extraction.quiescent_checkpoints,
);
// Render the cumulative wall-clock with the same human shape
// the progress renderer uses for ETA (e.g. `2m14s`, `1h7m3s`)
// so the final summary is at-a-glance readable even for
// resumed runs whose total spans hours or days. The value is
// sourced from `RunStats::elapsed`, which the coordinator
// populates as `prior_elapsed + started.elapsed()` — the
// accumulator survives crash-resumes via the checkpoint's v15
// trailer.
eprintln!("[stats] total_time={}", format_eta(Some(stats.elapsed)));
Ok(())
}
/// Drive the local-file extractor
/// (`internal/old/PLAN_local_file_extract.md`). Mirrors the HTTP path's
/// renderer / IO-backend / kill-switch wiring but skips the
/// download-side retry loop entirely: a local extraction either
/// completes or surfaces a clean error.
///
/// Local mode is non-destructive by default — the source archive
/// is preserved on disk. `-d/--destructive` opts into hole-punching
/// the source as the decoder advances and deleting it on clean
/// completion; the choice is encoded in [`LocalRunArgs::destructive`]
/// before this function is called, so there is nothing left to
/// prompt or confirm here.
fn run_local_dispatch(
mut args: LocalRunArgs,
stderr_is_tty: bool,
kill_switch: &Arc<AtomicBool>,
cleanup_done: Arc<AtomicBool>,
) -> Result<()> {
// Resolve the IO backend in `main` so the `io_backend=…`
// banner is plain stderr scrollback ABOVE the renderer's
// redraw region (mirroring the HTTP path). Local mode passes
// workers=1 because there is one logical reader and no
// download-worker grid.
let (io_backend, io_backend_label) = peel::io_backend::select_backend(args.io_backend, 1)
.context("resolving the IO backend")?;
args.io_backend_resolved = Some(io_backend);
if stderr_is_tty {
eprintln!("{io_backend_label}");
}
let state = ProgressState::new();
args.progress_state = Some(Arc::clone(&state));
args.progress = Some(make_event_callback(stderr_is_tty));
args.kill_switch = Some(Arc::clone(kill_switch));
let render_handle = if stderr_is_tty {
spawn_renderer(
Arc::clone(&state),
TtyRenderer::new(std::io::stderr()),
Duration::from_millis(100),
Some(Arc::clone(kill_switch)),
)
.context("spawning the TTY progress renderer")?
} else {
spawn_renderer(
Arc::clone(&state),
LogRenderer::new(),
Duration::from_secs(2),
Some(Arc::clone(kill_switch)),
)
.context("spawning the log progress renderer")?
};
let result = run_local(args);
state.mark_done();
let _ = render_handle.join();
cleanup_done.store(true, Ordering::Release);
let stats = match result {
Ok(stats) => stats,
Err(CoordinatorError::Aborted {
checkpoints_written,
}) => {
let sig = FIRST_SIGNAL.load(Ordering::Acquire);
eprintln!(
"[abort] {} received, exited after {} checkpoints \
(.peel.ckpt left for resume)",
signal_name(sig),
checkpoints_written,
);
std::process::exit(128 + sig);
}
Err(other) => {
let wrapped = anyhow::Error::from(other).context("running peel");
if password_exit_code_required(&wrapped) {
eprintln!("[error] {wrapped:#}");
std::process::exit(4);
}
return Err(wrapped);
}
};
eprintln!(
"[done] {} bytes extracted in {:.2}s{}",
stats.extraction.bytes_out,
stats.elapsed.as_secs_f64(),
if stats.resumed { " (resumed)" } else { "" },
);
eprintln!(
"[stats] extract bytes_in={} bytes_out={} bytes_punched={} \
frames={} checkpoints={}",
stats.extraction.bytes_in,
stats.extraction.bytes_out,
stats.extraction.bytes_punched,
stats.extraction.frame_boundaries_observed,
stats.extraction.quiescent_checkpoints,
);
// Cumulative wall-clock across every run that contributed —
// see the HTTP path's identical line for the rationale.
eprintln!("[stats] total_time={}", format_eta(Some(stats.elapsed)));
Ok(())
}
/// Discrete-event ProgressFn callback. Mostly informational; the
/// renderer thread covers the steady-state UI.
///
/// On a TTY this is intentionally a near-no-op: any concurrent
/// `eprintln!` while the renderer is mid-redraw lands inside its
/// 3-line region and corrupts the cursor-up math (a long URL in the
/// `[start]` line, for example, can wrap across 2 visual rows on an
/// 80-col terminal — the renderer's next tick then undershoots the
/// cursor-up and the previous tick's body lines stick around as
/// duplicates above the new draw). The `[done]`/`[stats]` summary
/// runs after the renderer has joined, so it's safe.
///
/// Off-TTY (the [`LogRenderer`] path) the redraw concern doesn't
/// apply, so we keep the discrete `[start]`/`[stats]` lines for log
/// readers.
fn make_event_callback(stderr_is_tty: bool) -> ProgressFn {
Box::new(move |event: ProgressEvent<'_>| {
if stderr_is_tty {
return;
}
match event {
ProgressEvent::Started {
url,
total_size,
resuming,
total_chunks,
chunks_resumed,
} => {
eprintln!(
"[start] {} ({} bytes, {} chunks{}{})",
url,
total_size,
total_chunks,
if resuming { ", resuming" } else { "" },
if chunks_resumed > 0 {
format!(", {chunks_resumed} chunks already complete")
} else {
String::new()
},
);
}
ProgressEvent::CheckpointWritten { .. } => {
// Per-checkpoint events are noisy on a fast pipeline; the
// renderer thread shows steady-state progress already.
}
ProgressEvent::Finished {
download,
extraction,
} => {
eprintln!(
"[stats] download chunks={} retries={} mode={:?}",
download.chunks_completed, download.retries, download.mode,
);
eprintln!(
"[stats] extract bytes_in={} bytes_out={} bytes_punched={} \
frames={} checkpoints={}",
extraction.bytes_in,
extraction.bytes_out,
extraction.bytes_punched,
extraction.frame_boundaries_observed,
extraction.quiescent_checkpoints,
);
}
}
})
}
/// Configure the global `tracing` subscriber.
///
/// On a TTY we suppress the `peel::progress` target entirely — the
/// TTY renderer is the user's view of that data, and dumping log
/// events to the same stream would corrupt the in-place redraw.
/// Off-TTY we keep the target on so the [`LogRenderer`] events make
/// it to stderr.
fn init_tracing(stderr_is_tty: bool) {
use tracing_subscriber::fmt;
let builder = fmt()
.with_writer(std::io::stderr)
.with_target(false)
.with_level(true)
.without_time();
if stderr_is_tty {
// Default level INFO but progress events suppressed: the
// renderer's in-place redraw is the user's progress view.
// Other targets (warnings, info from other modules) still
// show.
let _ = builder.with_max_level(tracing::Level::WARN).try_init();
} else {
let _ = builder.with_max_level(tracing::Level::INFO).try_init();
}
}
/// Default number of additional `run` attempts after the first one
/// fails with a transient error. Override via `PEEL_OUTER_RETRIES`.
const DEFAULT_OUTER_RETRIES: u32 = 5;
/// Initial delay between the first failure and its retry. Doubles up
/// to [`OUTER_RETRY_MAX_BACKOFF`].
const OUTER_RETRY_INITIAL_BACKOFF: Duration = Duration::from_secs(5);
/// Cap on the exponential backoff between retry attempts. The user
/// already endured a multi-minute failure window before reaching this
/// path, so a one-minute ceiling is plenty — anything longer just
/// stretches the operator's pager without improving recovery odds.
const OUTER_RETRY_MAX_BACKOFF: Duration = Duration::from_secs(60);
/// Read `PEEL_OUTER_RETRIES` (non-negative integer) and fall back to
/// [`DEFAULT_OUTER_RETRIES`]. `0` disables the outer-loop retry
/// entirely (one attempt, no restarts).
fn outer_retries_from_env() -> u32 {
std::env::var("PEEL_OUTER_RETRIES")
.ok()
.and_then(|s| s.trim().parse::<u32>().ok())
.unwrap_or(DEFAULT_OUTER_RETRIES)
}
/// Drive [`run`] in an outer retry loop: on a transient failure (a
/// download-side error whose root `WorkerError` / `SchedulerError`
/// reports `is_retryable`), wait, reset the shared progress counters,
/// rebuild a fresh `RunArgs` (fresh HTTP `Client` connection pool,
/// fresh `ProgressFn`), and call [`run`] again. The checkpoint and
/// part-file on disk make the next attempt resume losslessly from
/// where the failed one left off.
///
/// Non-retryable errors (`Aborted`, `SourceChanged*`, format-detection
/// conflicts, sparse-file IO, integrity mismatches, …) bypass the
/// retry path and surface immediately.
///
/// `kill_switch` is polled both before each retry and during the
/// backoff sleep so a SIGINT/SIGTERM during the wait window terminates
/// promptly instead of waiting out the full backoff.
fn run_with_outer_retry(
initial: RunArgs,
rebuild_args: &dyn Fn() -> RunArgs,
state: &Arc<ProgressState>,
kill_switch: &Arc<AtomicBool>,
stderr_is_tty: bool,
) -> Result<RunStats, CoordinatorError> {
let max_retries = outer_retries_from_env();
let mut args = initial;
let mut backoff = OUTER_RETRY_INITIAL_BACKOFF;
let mut attempt: u32 = 1;
loop {
match run(args) {
Ok(stats) => return Ok(stats),
Err(err) => {
let exhausted = attempt > max_retries;
let killed = kill_switch.load(Ordering::Acquire);
if exhausted || killed || !is_retryable_run_error(&err) {
return Err(err);
}
emit_retry_notice(&err, attempt, max_retries, backoff, stderr_is_tty);
if !sleep_with_kill_switch(backoff, kill_switch) {
return Err(err);
}