Skip to main content

mz_prof_http/
lib.rs

1// Copyright Materialize, Inc. and contributors. All rights reserved.
2//
3// Use of this software is governed by the Business Source License
4// included in the LICENSE file.
5//
6// As of the Change Date specified in that file, in accordance with
7// the Business Source License, use of this software will be governed
8// by the Apache License, Version 2.0.
9
10//! Profiling HTTP endpoints.
11
12use std::env;
13use std::sync::LazyLock;
14use std::sync::atomic::{AtomicBool, Ordering};
15use std::time::Duration;
16
17use askama::Template;
18use axum::Json;
19use axum::response::IntoResponse;
20use axum::routing::{self, Router};
21use cfg_if::cfg_if;
22use http::header::{CONTENT_DISPOSITION, CONTENT_TYPE};
23use http::{HeaderMap, HeaderValue, StatusCode};
24use mz_build_info::BuildInfo;
25use mz_prof::StackProfileExt;
26#[cfg(all(feature = "jemalloc", not(miri)))]
27use mz_prof::jemalloc::JemallocProfCtlExt;
28use pprof_util::{ProfStartTime, StackProfile};
29use serde::{Deserialize, Serialize};
30
31cfg_if! {
32    if #[cfg(any(not(feature = "jemalloc"), miri))] {
33        use disabled::{handle_get, handle_get_heap, handle_get_mode, handle_post, handle_post_mode};
34    } else {
35        use enabled::{handle_get, handle_get_heap, handle_get_mode, handle_post, handle_post_mode};
36    }
37}
38
39static EXECUTABLE: LazyLock<String> = LazyLock::new(|| {
40    {
41        env::current_exe()
42            .ok()
43            .as_ref()
44            .and_then(|exe| exe.file_name())
45            .map(|exe| exe.to_string_lossy().into_owned())
46            .unwrap_or_else(|| "<unknown executable>".into())
47    }
48});
49
50mz_http_util::make_handle_static!(
51    dir_1: ::include_dir::include_dir!("$CARGO_MANIFEST_DIR/src/http/static"),
52    dir_2: ::include_dir::include_dir!("$OUT_DIR/src/http/static"),
53    prod_base_path: "src/http/static",
54    dev_base_path: "src/http/static-dev",
55);
56
57/// Creates a router that serves the profiling endpoints.
58pub fn router(build_info: &'static BuildInfo) -> Router {
59    Router::new()
60        .route(
61            "/",
62            routing::get(move |query, headers| handle_get(query, headers, build_info)),
63        )
64        .route(
65            "/",
66            routing::post(move |form| handle_post(form, build_info)),
67        )
68        .route("/cpu", routing::post(handle_post_cpu))
69        .route(
70            "/mode",
71            routing::get(handle_get_mode).post(handle_post_mode),
72        )
73        .route("/heap", routing::get(handle_get_heap))
74        .route("/static/{*path}", routing::get(handle_static))
75}
76
77static CPU_PROFILING_ACTIVE: AtomicBool = AtomicBool::new(false);
78
79/// The maximum permitted CPU profile capture duration. A capture holds the
80/// jemalloc profiling control lock for its entire run, which blocks the heap
81/// profiling endpoints, so runaway requests must be bounded.
82const MAX_CPU_PROFILE_TIME_SECS: u64 = 3600;
83/// The maximum permitted CPU profile sampling frequency. Matches the limit
84/// enforced by `mz_prof::time::prof_time`.
85const MAX_CPU_PROFILE_HZ: u32 = 1_000_000;
86
87#[derive(Deserialize)]
88struct CpuProfileRequest {
89    seconds: u64,
90    hz: u32,
91    #[serde(default)]
92    merge_threads: bool,
93}
94
95/// A profiling mode update.
96#[derive(Debug, Deserialize)]
97struct ModeUpdateRequest {
98    memory_active: Option<bool>,
99}
100
101#[derive(Debug, Serialize)]
102struct ModeResponse {
103    cpu_active: bool,
104    memory_available: bool,
105    memory_active: bool,
106}
107
108fn cpu_profiling_active() -> bool {
109    CPU_PROFILING_ACTIVE.load(Ordering::SeqCst)
110}
111
112struct CpuProfilingGuard;
113
114impl CpuProfilingGuard {
115    fn acquire() -> Result<Self, (StatusCode, String)> {
116        CPU_PROFILING_ACTIVE
117            .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
118            .map_err(|_| {
119                (
120                    StatusCode::CONFLICT,
121                    "CPU profiling is already running".to_owned(),
122                )
123            })?;
124        Ok(Self)
125    }
126}
127
128impl Drop for CpuProfilingGuard {
129    fn drop(&mut self) {
130        CPU_PROFILING_ACTIVE.store(false, Ordering::SeqCst);
131    }
132}
133
134fn validate_cpu_profile_params(
135    time_secs: u64,
136    sample_freq: u32,
137) -> Result<(), (StatusCode, String)> {
138    if time_secs == 0 {
139        return Err((
140            StatusCode::BAD_REQUEST,
141            "`seconds` must be greater than zero".to_owned(),
142        ));
143    }
144    if time_secs > MAX_CPU_PROFILE_TIME_SECS {
145        return Err((
146            StatusCode::BAD_REQUEST,
147            format!("`seconds` must be at most {MAX_CPU_PROFILE_TIME_SECS}"),
148        ));
149    }
150    if sample_freq == 0 {
151        return Err((
152            StatusCode::BAD_REQUEST,
153            "`hz` must be greater than zero".to_owned(),
154        ));
155    }
156    if sample_freq > MAX_CPU_PROFILE_HZ {
157        return Err((
158            StatusCode::BAD_REQUEST,
159            format!("`hz` must be at most {MAX_CPU_PROFILE_HZ}"),
160        ));
161    }
162    Ok(())
163}
164
165fn cpu_pprof_response(mut stacks: StackProfile) -> impl IntoResponse {
166    // The sampler records raw runtime addresses. Attach the process memory
167    // mappings so that `to_pprof` can rebase addresses to be file-relative and
168    // offline tooling can symbolize them against the binary, like the heap
169    // profile path does via `parse_jeheap`.
170    if let Some(mappings) = mappings::MAPPINGS.as_ref() {
171        stacks.mappings = mappings.clone();
172    }
173    let pprof = stacks.to_pprof(("samples", "count"), ("cpu", "nanoseconds"), None);
174    (
175        HeaderMap::from_iter([
176            (
177                CONTENT_DISPOSITION,
178                HeaderValue::from_static("attachment; filename=\"cpu.pb.gz\""),
179            ),
180            (
181                CONTENT_TYPE,
182                HeaderValue::from_static("application/octet-stream"),
183            ),
184        ]),
185        pprof,
186    )
187}
188
189async fn handle_post_cpu(
190    Json(request): Json<CpuProfileRequest>,
191) -> Result<impl IntoResponse, (StatusCode, String)> {
192    let stacks = capture_cpu_profile(request.merge_threads, request.seconds, request.hz).await?;
193    Ok(cpu_pprof_response(stacks))
194}
195
196#[allow(dead_code)]
197enum MemProfilingStatus {
198    Disabled,
199    Enabled(Option<ProfStartTime>),
200}
201
202#[derive(Template)]
203#[template(path = "prof.html")]
204struct ProfTemplate<'a> {
205    version: &'a str,
206    executable: &'a str,
207    mem_prof: MemProfilingStatus,
208    ever_symbolized: bool,
209}
210
211#[derive(Template)]
212#[template(path = "flamegraph.html")]
213pub struct FlamegraphTemplate<'a> {
214    pub version: &'a str,
215    pub title: &'a str,
216    pub mzfg: &'a str,
217}
218
219/// Holds the jemalloc profiling control lock with memory profiling paused,
220/// restoring the prior state on drop.
221///
222/// Pauses rather than deactivates memory profiling: deactivation calls
223/// jemalloc's `prof.reset`, which discards the heap profile accumulated so far.
224/// See [`JemallocProfCtlExt::pause`].
225///
226/// Restoration lives in `Drop` so that it also runs when the capture future is
227/// cancelled, for example when the HTTP client disconnects mid-capture.
228#[cfg(all(feature = "jemalloc", not(miri)))]
229struct MemProfilingSuspendGuard {
230    ctl: tokio::sync::MutexGuard<'static, jemalloc_pprof::JemallocProfCtl>,
231    memory_was_active: bool,
232}
233
234#[cfg(all(feature = "jemalloc", not(miri)))]
235impl Drop for MemProfilingSuspendGuard {
236    fn drop(&mut self) {
237        if self.memory_was_active {
238            // There is no caller to report the error to during drop, so log
239            // instead. `GET /mode` exposes the resulting state.
240            if let Err(e) = self.ctl.resume() {
241                tracing::error!("failed to resume memory profiling after CPU profiling: {e}");
242            }
243        }
244    }
245}
246
247#[allow(dropping_copy_types)]
248async fn capture_cpu_profile(
249    merge_threads: bool,
250    // the time in seconds to run the profiler for
251    time_secs: u64,
252    // the sampling frequency in Hz
253    sample_freq: u32,
254) -> Result<StackProfile, (StatusCode, String)> {
255    validate_cpu_profile_params(time_secs, sample_freq)?;
256    let _cpu_profiling_guard = CpuProfilingGuard::acquire()?;
257    // Suspend memory profiling for the duration of the capture. The guard
258    // restores the prior state when dropped, which covers the success path,
259    // the error path, and cancellation. Holding the jemalloc control lock
260    // across the whole capture is what prevents anyone from re-activating
261    // memory profiling while the sampler runs.
262    let ctl_lock;
263    cfg_if! {
264        if #[cfg(any(not(feature = "jemalloc"), miri))] {
265            ctl_lock = ();
266        } else {
267            ctl_lock = match jemalloc_pprof::PROF_CTL.as_ref() {
268                Some(ctl) => {
269                    // Acquire the jemalloc memory profiling control lock
270                    // to ensure that no other thread can re-activate memory profiling
271                    let borrow = ctl.lock().await;
272                    // Check if memory profiling is currently active
273                    let memory_was_active = borrow.activated();
274                    // If it is, pause it. Pause rather than deactivate: the
275                    // latter calls jemalloc's `prof.reset`, which would discard
276                    // the heap profile accumulated so far.
277                    if memory_was_active {
278                        borrow.pause().map_err(|e| {
279                            (StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
280                        })?;
281                    }
282                    // Return a guard where memory profiling is suspended and will be restored
283                    // when the guard is dropped
284                    Some(MemProfilingSuspendGuard {
285                        ctl: borrow,
286                        memory_was_active,
287                    })
288                }
289                None => None,
290            };
291        }
292    }
293    // SAFETY: We ensure above that memory profiling is off.
294    // Since we hold the mutex, nobody else can be turning it back on in the intervening time.
295    let stacks = unsafe {
296        mz_prof::time::prof_time(Duration::from_secs(time_secs), sample_freq, merge_threads)
297    }
298    .await
299    .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
300    // The sampler has stopped, so the guard may now restore memory profiling.
301    // Referencing `ctl_lock` here also fails with a compile error if we
302    // weren't holding the jemalloc lock across the capture.
303    drop(ctl_lock);
304    Ok(stacks)
305}
306
307async fn time_prof(
308    merge_threads: bool,
309    build_info: &'static BuildInfo,
310    // the time in seconds to run the profiler for
311    time_secs: u64,
312    // the sampling frequency in Hz
313    sample_freq: u32,
314) -> impl IntoResponse + use<> {
315    let stacks = capture_cpu_profile(merge_threads, time_secs, sample_freq).await?;
316    let (secs_s, freq_s) = (format!("{time_secs}"), format!("{sample_freq}"));
317    Ok::<_, (StatusCode, String)>(flamegraph(
318        stacks,
319        "CPU Time Flamegraph",
320        false,
321        &[
322            ("Sampling time (s)", &secs_s),
323            ("Sampling frequency (Hz)", &freq_s),
324        ],
325        build_info,
326    ))
327}
328
329fn flamegraph<'a, 'b>(
330    stacks: StackProfile,
331    title: &'a str,
332    display_bytes: bool,
333    extras: &'b [(&'b str, &'b str)],
334    build_info: &'static BuildInfo,
335) -> impl IntoResponse + use<'a> {
336    let mut header_extra = vec![];
337    if display_bytes {
338        header_extra.push(("display_bytes", "1"));
339    }
340    for (k, v) in extras {
341        header_extra.push((k, v));
342    }
343    let mzfg = stacks.to_mzfg(true, &header_extra);
344    mz_http_util::template_response(FlamegraphTemplate {
345        version: build_info.version,
346        title,
347        mzfg: &mzfg,
348    })
349}
350
351#[cfg(any(not(feature = "jemalloc"), miri))]
352mod disabled {
353    use axum::Json;
354    use axum::extract::{Form, Query};
355    use axum::response::IntoResponse;
356    use http::StatusCode;
357    use http::header::HeaderMap;
358    use mz_build_info::BuildInfo;
359    use serde::Deserialize;
360
361    use mz_prof::ever_symbolized;
362
363    use super::{
364        MemProfilingStatus, ModeResponse, ModeUpdateRequest, ProfTemplate, cpu_profiling_active,
365        time_prof,
366    };
367
368    #[derive(Deserialize)]
369    pub struct ProfQuery {
370        _action: Option<String>,
371    }
372
373    #[allow(clippy::unused_async)]
374    pub async fn handle_get(
375        _: Query<ProfQuery>,
376        _: HeaderMap,
377        build_info: &'static BuildInfo,
378    ) -> impl IntoResponse {
379        mz_http_util::template_response(ProfTemplate {
380            version: build_info.version,
381            executable: &super::EXECUTABLE,
382            mem_prof: MemProfilingStatus::Disabled,
383            ever_symbolized: ever_symbolized(),
384        })
385    }
386
387    #[derive(Deserialize)]
388    pub struct ProfForm {
389        action: String,
390        threads: Option<String>,
391        time_secs: Option<u64>,
392        hz: Option<u32>,
393    }
394
395    pub async fn handle_post(
396        Form(ProfForm {
397            action,
398            threads,
399            time_secs,
400            hz,
401        }): Form<ProfForm>,
402        build_info: &'static BuildInfo,
403    ) -> impl IntoResponse {
404        let merge_threads = threads.as_deref() == Some("merge");
405        match action.as_ref() {
406            "time_fg" => {
407                let time_secs = time_secs.ok_or_else(|| {
408                    (
409                        StatusCode::BAD_REQUEST,
410                        "Expected value for `time_secs`".to_owned(),
411                    )
412                })?;
413                let hz = hz.ok_or_else(|| {
414                    (
415                        StatusCode::BAD_REQUEST,
416                        "Expected value for `hz`".to_owned(),
417                    )
418                })?;
419
420                Ok(time_prof(merge_threads, build_info, time_secs, hz).await)
421            }
422            _ => Err((
423                StatusCode::BAD_REQUEST,
424                format!("unrecognized `action` parameter: {}", action),
425            )),
426        }
427    }
428
429    #[allow(clippy::unused_async)]
430    pub async fn handle_get_mode() -> Json<ModeResponse> {
431        Json(ModeResponse {
432            cpu_active: cpu_profiling_active(),
433            memory_available: false,
434            memory_active: false,
435        })
436    }
437
438    pub async fn handle_post_mode(
439        Json(request): Json<ModeUpdateRequest>,
440    ) -> Result<Json<ModeResponse>, (StatusCode, String)> {
441        if request.memory_active == Some(true) {
442            return Err((
443                StatusCode::FORBIDDEN,
444                "memory profiling is unavailable in this build".to_owned(),
445            ));
446        }
447        Ok(handle_get_mode().await)
448    }
449
450    #[allow(clippy::unused_async)]
451    pub async fn handle_get_heap() -> Result<(), (StatusCode, String)> {
452        Err((
453            StatusCode::BAD_REQUEST,
454            "This software was compiled without heap profiling support.".to_string(),
455        ))
456    }
457}
458
459#[cfg(all(feature = "jemalloc", not(miri)))]
460mod enabled {
461    use std::io::{BufReader, Read};
462    use std::sync::Arc;
463
464    use axum::Json;
465    use axum::extract::{Form, Query};
466    use axum::response::IntoResponse;
467    use axum_extra::TypedHeader;
468    use bytesize::ByteSize;
469    use headers::ContentType;
470    use http::header::{CONTENT_DISPOSITION, HeaderMap};
471    use http::{HeaderValue, StatusCode};
472    use jemalloc_pprof::{JemallocProfCtl, PROF_CTL};
473    use mappings::MAPPINGS;
474    use mz_build_info::BuildInfo;
475    use mz_ore::cast::CastFrom;
476    use mz_prof::jemalloc::{JemallocProfCtlExt, JemallocStats};
477    use mz_prof::{StackProfileExt, ever_symbolized};
478    use pprof_util::parse_jeheap;
479    use serde::Deserialize;
480    use tokio::sync::Mutex;
481
482    use super::{
483        MemProfilingStatus, ModeResponse, ModeUpdateRequest, ProfTemplate, cpu_profiling_active,
484        flamegraph, time_prof,
485    };
486
487    #[derive(Deserialize)]
488    pub struct ProfForm {
489        action: String,
490        threads: Option<String>,
491        time_secs: Option<u64>,
492        hz: Option<u32>,
493    }
494
495    pub async fn handle_post(
496        Form(ProfForm {
497            action,
498            threads,
499            time_secs,
500            hz,
501        }): Form<ProfForm>,
502        build_info: &'static BuildInfo,
503    ) -> impl IntoResponse {
504        let prof_ctl = PROF_CTL.as_ref().unwrap();
505        let merge_threads = threads.as_deref() == Some("merge");
506
507        fn render_jemalloc_stats(stats: &JemallocStats) -> Vec<(&str, String)> {
508            let stats = [
509                ("Allocated", stats.allocated),
510                ("In active pages", stats.active),
511                ("Allocated for allocator metadata", stats.metadata),
512                (
513                    "Maximum number of bytes in physically resident data pages mapped by the allocator",
514                    stats.resident,
515                ),
516                ("Bytes unused, but retained by allocator", stats.retained),
517            ];
518            stats
519                .into_iter()
520                .map(|(k, v)| (k, ByteSize(u64::cast_from(v)).display().si().to_string()))
521                .collect()
522        }
523
524        match action.as_str() {
525            "activate" => {
526                {
527                    let mut borrow = prof_ctl.lock().await;
528                    borrow
529                        .activate()
530                        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
531                };
532                Ok(render_template(prof_ctl, build_info).await.into_response())
533            }
534            "deactivate" => {
535                {
536                    let mut borrow = prof_ctl.lock().await;
537                    borrow
538                        .deactivate()
539                        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
540                };
541                Ok(render_template(prof_ctl, build_info).await.into_response())
542            }
543            "dump_jeheap" => {
544                let mut borrow = prof_ctl.lock().await;
545                require_profiling_activated(&borrow)?;
546                let mut f = borrow
547                    .dump()
548                    .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
549                let mut s = String::new();
550                f.read_to_string(&mut s)
551                    .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
552                Ok((
553                    HeaderMap::from_iter([(
554                        CONTENT_DISPOSITION,
555                        HeaderValue::from_static("attachment; filename=\"jeprof.heap\""),
556                    )]),
557                    s,
558                )
559                    .into_response())
560            }
561            "dump_sym_mzfg" => {
562                let mut borrow = prof_ctl.lock().await;
563                require_profiling_activated(&borrow)?;
564                let f = borrow
565                    .dump()
566                    .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
567                let r = BufReader::new(f);
568                let stacks = parse_jeheap(r, MAPPINGS.as_deref())
569                    .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
570                let stats = borrow
571                    .stats()
572                    .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
573                let stats_rendered = render_jemalloc_stats(&stats);
574                let mut header = stats_rendered
575                    .iter()
576                    .map(|(k, v)| (*k, v.as_str()))
577                    .collect::<Vec<_>>();
578                header.push(("display_bytes", "1"));
579                let mzfg = stacks.to_mzfg(true, &header);
580                Ok((
581                    HeaderMap::from_iter([(
582                        CONTENT_DISPOSITION,
583                        HeaderValue::from_static("attachment; filename=\"trace.mzfg\""),
584                    )]),
585                    mzfg,
586                )
587                    .into_response())
588            }
589            "mem_fg" => {
590                let mut borrow = prof_ctl.lock().await;
591                require_profiling_activated(&borrow)?;
592                let f = borrow
593                    .dump()
594                    .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
595                let r = BufReader::new(f);
596                let stacks = parse_jeheap(r, MAPPINGS.as_deref())
597                    .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
598                let stats = borrow
599                    .stats()
600                    .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
601                let stats_rendered = render_jemalloc_stats(&stats);
602                let stats_rendered = stats_rendered
603                    .iter()
604                    .map(|(k, v)| (*k, v.as_str()))
605                    .collect::<Vec<_>>();
606                Ok(
607                    flamegraph(stacks, "Heap Flamegraph", true, &stats_rendered, build_info)
608                        .into_response(),
609                )
610            }
611            "time_fg" => {
612                let time_secs = time_secs.ok_or_else(|| {
613                    (
614                        StatusCode::BAD_REQUEST,
615                        "Expected value for `time_secs`".to_owned(),
616                    )
617                })?;
618                let hz = hz.ok_or_else(|| {
619                    (
620                        StatusCode::BAD_REQUEST,
621                        "Expected value for `hz`".to_owned(),
622                    )
623                })?;
624                Ok(time_prof(merge_threads, build_info, time_secs, hz)
625                    .await
626                    .into_response())
627            }
628            x => Err((
629                StatusCode::BAD_REQUEST,
630                format!("unrecognized `action` parameter: {}", x),
631            )),
632        }
633    }
634
635    #[derive(Deserialize)]
636    pub struct ProfQuery {
637        action: Option<String>,
638    }
639
640    pub async fn handle_get(
641        Query(query): Query<ProfQuery>,
642        headers: HeaderMap,
643        build_info: &'static BuildInfo,
644    ) -> impl IntoResponse {
645        let prof_ctl = PROF_CTL.as_ref().unwrap();
646        match query.action.as_deref() {
647            Some("dump_stats") => {
648                let json = headers
649                    .get("accept")
650                    .map_or(false, |accept| accept.as_bytes() == b"application/json");
651                let mut borrow = prof_ctl.lock().await;
652                let s = borrow
653                    .dump_stats(json)
654                    .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
655                let content_type = match json {
656                    false => ContentType::text(),
657                    true => ContentType::json(),
658                };
659                Ok((TypedHeader(content_type), s).into_response())
660            }
661            Some(x) => Err((
662                StatusCode::BAD_REQUEST,
663                format!("unrecognized query: {}", x),
664            )),
665            None => Ok(render_template(prof_ctl, build_info).await.into_response()),
666        }
667    }
668
669    pub async fn handle_get_mode() -> Json<ModeResponse> {
670        let memory_active = current_memory_mode().await;
671        Json(mode_response(memory_active, prof_ctl().is_some()))
672    }
673
674    pub async fn handle_post_mode(
675        Json(request): Json<ModeUpdateRequest>,
676    ) -> Result<Json<ModeResponse>, (StatusCode, String)> {
677        if let Some(desired_memory_active) = request.memory_active {
678            // NOTE: This check is a fast fail. The guarantee that memory
679            // profiling cannot be activated mid-capture is the jemalloc
680            // control lock below, which a running capture holds for its
681            // entire duration.
682            if desired_memory_active && cpu_profiling_active() {
683                return Err((
684                    StatusCode::CONFLICT,
685                    "memory profiling cannot be activated while CPU profiling is running"
686                        .to_owned(),
687                ));
688            }
689            let prof_ctl = prof_ctl().ok_or_else(|| {
690                (
691                    StatusCode::FORBIDDEN,
692                    "memory profiling is unavailable in this build".to_owned(),
693                )
694            })?;
695            let mut borrow = prof_ctl.lock().await;
696            if desired_memory_active && !borrow.activated() {
697                borrow
698                    .activate()
699                    .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
700            } else if !desired_memory_active && borrow.activated() {
701                borrow
702                    .deactivate()
703                    .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
704            }
705            return Ok(Json(mode_response(borrow.activated(), true)));
706        }
707        Ok(Json(mode_response(
708            current_memory_mode().await,
709            prof_ctl().is_some(),
710        )))
711    }
712
713    pub async fn handle_get_heap() -> Result<impl IntoResponse, (StatusCode, String)> {
714        let mut prof_ctl = PROF_CTL.as_ref().unwrap().lock().await;
715        require_profiling_activated(&prof_ctl)?;
716        let dump_file = prof_ctl
717            .dump()
718            .map_err(|err| (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()))?;
719        let dump_reader = BufReader::new(dump_file);
720        let profile = parse_jeheap(dump_reader, MAPPINGS.as_deref())
721            .map_err(|err| (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()))?;
722        let pprof = profile.to_pprof(("inuse_space", "bytes"), ("space", "bytes"), None);
723        Ok(pprof)
724    }
725
726    async fn render_template(
727        prof_ctl: &Arc<Mutex<JemallocProfCtl>>,
728        build_info: &'static BuildInfo,
729    ) -> impl IntoResponse {
730        let prof_md = prof_ctl.lock().await.get_md();
731        mz_http_util::template_response(ProfTemplate {
732            version: build_info.version,
733            executable: &super::EXECUTABLE,
734            mem_prof: MemProfilingStatus::Enabled(prof_md.start_time),
735            ever_symbolized: ever_symbolized(),
736        })
737    }
738
739    /// Checks whether jemalloc profiling is activated an returns an error response if not.
740    fn require_profiling_activated(prof_ctl: &JemallocProfCtl) -> Result<(), (StatusCode, String)> {
741        if prof_ctl.activated() {
742            Ok(())
743        } else {
744            Err((StatusCode::FORBIDDEN, "heap profiling not activated".into()))
745        }
746    }
747
748    async fn current_memory_mode() -> bool {
749        match prof_ctl() {
750            Some(prof_ctl) => prof_ctl.lock().await.activated(),
751            None => false,
752        }
753    }
754
755    fn prof_ctl() -> Option<Arc<Mutex<JemallocProfCtl>>> {
756        // NOTE: Dereferencing `PROF_CTL` panics when the linked jemalloc was
757        // built without profiling support, e.g. in test binaries that enable
758        // the `jemalloc` feature without configuring the allocator. Treat
759        // that as memory profiling being unavailable. `mz_ore`'s wrapper,
760        // unlike `std::panic::catch_unwind`, cooperates with our panic hook,
761        // which otherwise aborts the process.
762        mz_ore::panic::catch_unwind(|| PROF_CTL.as_ref().cloned())
763            .ok()
764            .flatten()
765    }
766
767    fn mode_response(memory_active: bool, memory_available: bool) -> ModeResponse {
768        ModeResponse {
769            cpu_active: cpu_profiling_active(),
770            memory_available,
771            memory_active,
772        }
773    }
774}
775
776#[cfg(test)]
777mod tests {
778    use axum::Json;
779    use http::StatusCode;
780
781    use super::{
782        CpuProfileRequest, CpuProfilingGuard, MAX_CPU_PROFILE_TIME_SECS, ModeResponse,
783        ModeUpdateRequest, handle_get_mode, handle_post_cpu, handle_post_mode,
784    };
785
786    /// Serializes tests that acquire the process-global [`CpuProfilingGuard`],
787    /// since tests within one binary run concurrently.
788    static CPU_GUARD_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
789
790    fn expect_error(
791        result: Result<impl axum::response::IntoResponse, (StatusCode, String)>,
792        msg: &str,
793    ) -> StatusCode {
794        match result {
795            Ok(_) => panic!("{}", msg),
796            Err((status, _)) => status,
797        }
798    }
799
800    #[mz_ore::test(tokio::test)]
801    async fn post_cpu_rejects_zero_seconds() {
802        let result = handle_post_cpu(Json(CpuProfileRequest {
803            seconds: 0,
804            hz: 99,
805            merge_threads: false,
806        }))
807        .await;
808        let status = expect_error(result, "zero-second capture must fail");
809        assert_eq!(status, StatusCode::BAD_REQUEST);
810    }
811
812    #[mz_ore::test(tokio::test)]
813    async fn post_cpu_rejects_zero_hz() {
814        let result = handle_post_cpu(Json(CpuProfileRequest {
815            seconds: 1,
816            hz: 0,
817            merge_threads: false,
818        }))
819        .await;
820        let status = expect_error(result, "zero-hz capture must fail");
821        assert_eq!(status, StatusCode::BAD_REQUEST);
822    }
823
824    #[mz_ore::test(tokio::test)]
825    async fn post_cpu_rejects_excessive_seconds() {
826        let result = handle_post_cpu(Json(CpuProfileRequest {
827            seconds: MAX_CPU_PROFILE_TIME_SECS + 1,
828            hz: 99,
829            merge_threads: false,
830        }))
831        .await;
832        let status = expect_error(result, "over-long capture must fail");
833        assert_eq!(status, StatusCode::BAD_REQUEST);
834    }
835
836    #[mz_ore::test(tokio::test)]
837    async fn post_cpu_conflicts_with_running_capture() {
838        let _lock = CPU_GUARD_LOCK.lock().await;
839        let _guard = CpuProfilingGuard::acquire().expect("no capture running");
840        let result = handle_post_cpu(Json(CpuProfileRequest {
841            seconds: 1,
842            hz: 99,
843            merge_threads: false,
844        }))
845        .await;
846        let status = expect_error(result, "concurrent capture must fail");
847        assert_eq!(status, StatusCode::CONFLICT);
848    }
849
850    #[mz_ore::test(tokio::test)]
851    async fn get_mode_reports_cpu_active_state() {
852        let _lock = CPU_GUARD_LOCK.lock().await;
853        let guard = CpuProfilingGuard::acquire().expect("no capture running");
854        let Json(ModeResponse { cpu_active, .. }) = handle_get_mode().await;
855        assert!(cpu_active);
856        drop(guard);
857        let Json(ModeResponse { cpu_active, .. }) = handle_get_mode().await;
858        assert!(!cpu_active);
859    }
860
861    #[mz_ore::test(tokio::test)]
862    async fn post_mode_without_memory_change_is_noop() {
863        let _lock = CPU_GUARD_LOCK.lock().await;
864        let _guard = CpuProfilingGuard::acquire().expect("no capture running");
865        let result = handle_post_mode(Json(ModeUpdateRequest {
866            memory_active: None,
867        }))
868        .await;
869        let _ = result.expect("mode update without a memory change must succeed");
870    }
871
872    #[cfg(any(not(feature = "jemalloc"), miri))]
873    #[mz_ore::test(tokio::test)]
874    async fn post_mode_rejects_memory_activation_when_unavailable() {
875        let result = handle_post_mode(Json(ModeUpdateRequest {
876            memory_active: Some(true),
877        }))
878        .await;
879        let (status, _) = result.expect_err("memory activation must fail without jemalloc");
880        assert_eq!(status, StatusCode::FORBIDDEN);
881    }
882
883    #[cfg(all(feature = "jemalloc", not(miri)))]
884    #[mz_ore::test(tokio::test)]
885    async fn get_mode_handles_optional_memory_support() {
886        let Json(ModeResponse {
887            memory_available,
888            memory_active,
889            ..
890        }) = handle_get_mode().await;
891        assert!(!memory_active || memory_available);
892    }
893
894    /// End-to-end check of the memory-profiling lifecycle around a CPU capture:
895    /// memory profiling starts active, is suspended for the duration of a
896    /// capture (during which it cannot be re-activated), and is restored once
897    /// the capture finishes.
898    #[cfg(all(feature = "jemalloc", not(miri)))]
899    #[mz_ore::test(tokio::test)]
900    async fn cpu_capture_suspends_then_restores_memory_profiling() {
901        use super::{capture_cpu_profile, cpu_profiling_active};
902
903        let _lock = CPU_GUARD_LOCK.lock().await;
904
905        // Without a profiling-enabled jemalloc there is nothing to suspend, so
906        // the behavior under test does not exist here. Skip loudly rather than
907        // fail, so narrow local builds do not report a spurious failure.
908        let Json(ModeResponse {
909            memory_available, ..
910        }) = handle_get_mode().await;
911        if !memory_available {
912            eprintln!(
913                "skipping cpu_capture_suspends_then_restores_memory_profiling: \
914                 jemalloc memory profiling is unavailable in this build"
915            );
916            return;
917        }
918
919        // (a) Memory profiling on, CPU profiling off.
920        let Json(before) = handle_post_mode(Json(ModeUpdateRequest {
921            memory_active: Some(true),
922        }))
923        .await
924        .expect("memory activation must succeed");
925        assert!(before.memory_active, "memory profiling should start active");
926        assert!(!before.cpu_active, "cpu profiling should start inactive");
927
928        // (b) Run a capture on its own task so we can observe the live state
929        // while it holds the jemalloc control lock. `GET /mode` reads that lock
930        // and would block until the capture finished, so we observe through the
931        // lock-free CPU atomic and the fast-fail `POST /mode` path instead.
932        let capture =
933            mz_ore::task::spawn(|| "cpu-capture-test", capture_cpu_profile(false, 1, 100));
934        while !cpu_profiling_active() && !capture.is_finished() {
935            tokio::task::yield_now().await;
936        }
937        assert!(
938            cpu_profiling_active(),
939            "the capture should have activated cpu profiling"
940        );
941        // Memory profiling is suspended: re-activating it is rejected while the
942        // capture holds the control lock.
943        let conflict = handle_post_mode(Json(ModeUpdateRequest {
944            memory_active: Some(true),
945        }))
946        .await;
947        assert_eq!(
948            expect_error(conflict, "memory activation must be rejected mid-capture"),
949            StatusCode::CONFLICT,
950        );
951
952        // (c) Once the capture finishes, memory profiling is restored and CPU
953        // profiling is off.
954        let _ = capture.await.expect("cpu capture must succeed");
955        let Json(after) = handle_get_mode().await;
956        assert!(
957            !after.cpu_active,
958            "cpu profiling should be inactive after the capture"
959        );
960        assert!(
961            after.memory_active,
962            "memory profiling should be restored after the capture"
963        );
964    }
965}