mirror of https://lore.kernel.org/lkml/
 help / color / mirror / Atom feed
From: John Hubbard <jhubbard@nvidia.com>
To: Danilo Krummrich <dakr@kernel.org>,
	Alexandre Courbot <acourbot@nvidia.com>
Cc: "Timur Tabi" <ttabi@nvidia.com>,
	"Alistair Popple" <apopple@nvidia.com>,
	"Eliot Courtney" <ecourtney@nvidia.com>,
	"Zhi Wang" <zhiw@nvidia.com>, "David Airlie" <airlied@gmail.com>,
	"Simona Vetter" <simona@ffwll.ch>,
	"Bjorn Helgaas" <bhelgaas@google.com>,
	"Miguel Ojeda" <ojeda@kernel.org>,
	"Alex Gaynor" <alex.gaynor@gmail.com>,
	"Boqun Feng" <boqun.feng@gmail.com>,
	"Gary Guo" <gary@garyguo.net>,
	"Björn Roy Baron" <bjorn3_gh@protonmail.com>,
	"Benno Lossin" <lossin@kernel.org>,
	"Andreas Hindborg" <a.hindborg@kernel.org>,
	"Alice Ryhl" <aliceryhl@google.com>,
	"Trevor Gross" <tmgross@umich.edu>,
	nova-gpu@lists.linux.dev, LKML <linux-kernel@vger.kernel.org>,
	"John Hubbard" <jhubbard@nvidia.com>
Subject: [PATCH 2/4] gpu: nova-core: enable decoding of the GSP firmware logs
Date: Thu, 17 Sep 2026 19:12:47 -0700	[thread overview]
Message-ID: <20260918021249.1187837-3-jhubbard@nvidia.com> (raw)
In-Reply-To: <20260918021249.1187837-1-jhubbard@nvidia.com>

Nova-core exports the GSP-RM log buffers over debugfs, and Open RM's
log decoder turns a dump into text with the ELF of the firmware that
wrote it. The decoder identifies that firmware by a header ahead of
each buffer. The header, in the LIBOS_LOG_NVLOG_BUFFER_V2 layout, names
the GPU, the firmware task that wrote the buffer, and the GNU build ID
of the firmware.

Nova-core wrote no such header, so a dump could not be matched to its
firmware, and a decode with the wrong ELF produced garbage rather than
an error.

Prepend the header to each debugfs dump, with the build ID read from
the BLID tag of gsp.tlv. The log buffers are created before boot loads
the firmware from that file, so request the file once when the buffers
are created and hand it to the boot sequence. A file without a usable
build ID is reported with a warning, and its dumps carry no header.

Also, r000 has several more log buffers that must be provisioned, so do
that too.

Assisted-by: LLM
Reviewed-by: Timur Tabi <ttabi@nvidia.com>
Signed-off-by: John Hubbard <jhubbard@nvidia.com>
---
 drivers/gpu/nova-core/firmware/gsp.rs |  54 ++++++-
 drivers/gpu/nova-core/gpu.rs          |   3 +-
 drivers/gpu/nova-core/gsp.rs          | 212 ++++++++++++++++++++++----
 drivers/gpu/nova-core/gsp/boot.rs     |   2 +-
 4 files changed, 234 insertions(+), 37 deletions(-)

diff --git a/drivers/gpu/nova-core/firmware/gsp.rs b/drivers/gpu/nova-core/firmware/gsp.rs
index a2db7b6ba131..e6ca8d559d7b 100644
--- a/drivers/gpu/nova-core/firmware/gsp.rs
+++ b/drivers/gpu/nova-core/firmware/gsp.rs
@@ -6,6 +6,7 @@
         Coherent,
         DmaAddress, //
     },
+    firmware,
     prelude::*, //
 };
 
@@ -21,6 +22,48 @@
     gpu::Chipset, //
 };
 
+/// Longest build ID that a log dump header carries, matching Open RM's `BUILD_ID_MAX_LENGTH`.
+pub(crate) const BUILD_ID_MAX_LENGTH: usize = 32;
+
+/// Build ID of the GSP firmware, from the `BLID` tag of its TLV.
+pub(crate) struct BuildId {
+    /// The ID, zero-padded to [`BUILD_ID_MAX_LENGTH`] bytes.
+    bytes: [u8; BUILD_ID_MAX_LENGTH],
+    /// Number of valid bytes in `bytes`.
+    len: u32,
+}
+
+impl BuildId {
+    /// Reads the build ID from the `BLID` tag of `tlv`.
+    ///
+    /// Returns `EINVAL` if the tag is absent or empty, or if its value is longer than
+    /// [`BUILD_ID_MAX_LENGTH`] bytes.
+    pub(crate) fn from_tlv(tlv: &Tlv<'_>) -> Result<Self> {
+        let value = tlv.get_bytes(b"BLID")?;
+
+        let mut bytes = [0; BUILD_ID_MAX_LENGTH];
+        bytes
+            .get_mut(..value.len())
+            .ok_or(EINVAL)?
+            .copy_from_slice(value);
+
+        Ok(Self {
+            bytes,
+            len: u32::try_from(value.len())?,
+        })
+    }
+
+    /// Returns the ID, zero-padded to [`BUILD_ID_MAX_LENGTH`] bytes.
+    pub(crate) fn padded(&self) -> &[u8; BUILD_ID_MAX_LENGTH] {
+        &self.bytes
+    }
+
+    /// Returns the number of bytes in the ID.
+    pub(crate) fn len(&self) -> u32 {
+        self.len
+    }
+}
+
 /// The GSP firmware image, its signatures, and the GSP bootloader.
 #[pin_data]
 pub(crate) struct GspFirmware<'a> {
@@ -36,13 +79,16 @@ pub(crate) struct GspFirmware<'a> {
 impl<'a> GspFirmware<'a> {
     /// Loads the GSP firmware binaries, map them into `dev`'s address-space, and creates the page
     /// tables expected by the GSP bootloader to load it.
-    pub(crate) fn new(
+    ///
+    /// `gsp_tlv` is the TLV of the GSP firmware, which names the image file and carries the
+    /// signatures.
+    pub(crate) fn new<'tlv>(
         dev: &'a device::Device<device::Bound>,
         chipset: Chipset,
-    ) -> impl PinInit<Self, Error> + 'a {
+        gsp_tlv: &'tlv firmware::Firmware,
+    ) -> impl PinInit<Self, Error> + use<'a, 'tlv> {
         pin_init::pin_init_scope(move || {
-            let firmware = request_tlv(dev, chipset, "gsp")?;
-            let tlv = Tlv::new(firmware.data())?;
+            let tlv = Tlv::new(gsp_tlv.data())?;
             dev_dbg!(dev, "loaded gsp firmware v{}\n", tlv.get_string(b"VERS")?);
 
             let fw_vvec = tlv.load_file(dev, chipset)?;
diff --git a/drivers/gpu/nova-core/gpu.rs b/drivers/gpu/nova-core/gpu.rs
index cecd8e79430d..173c57179b39 100644
--- a/drivers/gpu/nova-core/gpu.rs
+++ b/drivers/gpu/nova-core/gpu.rs
@@ -220,7 +220,6 @@ pub(crate) struct Spec {
     pub(crate) chipset: Chipset,
     /// Implementation field of `NV_PMC_BOOT_42`, which distinguishes this chipset from the other
     /// chipsets of its architecture.
-    #[expect(dead_code)]
     pub(crate) implementation: u8,
     revision: Revision,
 }
@@ -424,7 +423,7 @@ pub(crate) fn new<'a>(
 
                 vgpu: VgpuManager::new(pdev, spec.chipset, fsp.as_mut()),
 
-                gsp <- Gsp::new(pdev, bar),
+                gsp <- Gsp::new(pdev, *spec, bar),
 
                 // This member must be initialized last, so that the unload bundle can never be
                 // dropped from outside the constructed `GspResources`, and the unload sequence runs
diff --git a/drivers/gpu/nova-core/gsp.rs b/drivers/gpu/nova-core/gsp.rs
index 85967119cd7e..f8c675288d58 100644
--- a/drivers/gpu/nova-core/gsp.rs
+++ b/drivers/gpu/nova-core/gsp.rs
@@ -12,13 +12,17 @@
         CoherentView,
         DmaAddress, //
     },
+    firmware,
+    fs::file,
     io::{
         io_project,
         io_write,
         Io, //
     },
     pci,
-    prelude::*, //
+    prelude::*,
+    transmute::AsBytes,
+    uaccess::UserSliceWriter, //
 };
 
 pub(crate) mod cmdq;
@@ -42,8 +46,21 @@
         sec2::Sec2 as Sec2Falcon,
         Falcon, //
     },
+    firmware::{
+        gsp::{
+            BuildId,
+            BUILD_ID_MAX_LENGTH, //
+        },
+        tlv::{
+            request_tlv,
+            Tlv, //
+        },
+    },
     fsp::Fsp,
-    gpu::Chipset,
+    gpu::{
+        Chipset,
+        Spec, //
+    },
     gsp::{
         cmdq::Cmdq,
         fw::GspArgumentsPadded, //
@@ -100,6 +117,68 @@ fn init(view: CoherentView<'_, Self>, start: DmaAddress) -> Result<()> {
     }
 }
 
+/// Longest task name in a log dump header, matching Open RM's `TASK_NAME_MAX_LENGTH`.
+const TASK_NAME_MAX_LENGTH: usize = 8;
+
+/// Header ahead of the log data in a debugfs dump, in the layout of Open RM's
+/// `LIBOS_LOG_NVLOG_BUFFER_V2`.
+///
+/// The decoder uses the header only when `build_id` matches the build ID of its GSP firmware ELF.
+#[repr(C)]
+struct LogBufferHeader {
+    /// Architecture code of the GPU, from the `NV_PMC_BOOT_42` architecture field.
+    gpu_arch: u32,
+    /// Implementation number of the GPU within its architecture.
+    gpu_impl: u32,
+    version: u32,
+    /// Number of valid bytes in `build_id`.
+    build_id_length: u32,
+    /// Name of the LIBOS3 task, printed ahead of each decoded line.
+    task_prefix: [u8; TASK_NAME_MAX_LENGTH],
+    /// Value that the decoder adds to each timestamp, zero when unknown.
+    local_to_global_timer_delta: u64,
+    /// Build ID of the GSP firmware, zero-padded.
+    build_id: [u8; BUILD_ID_MAX_LENGTH],
+    /// `LIBOS_LOG_NVLOG_BUFFER_FLAG_*` bits.
+    flags: u32,
+    reserved: u32,
+}
+
+// SAFETY: `LogBufferHeader` is `repr(C)`, its integer and byte array fields leave no padding
+// between or after them, and it has no interior mutability.
+unsafe impl AsBytes for LogBufferHeader {}
+
+impl LogBufferHeader {
+    /// The `LIBOS_LOG_NVLOG_BUFFER_V2` layout.
+    const VERSION: u32 = 2;
+    /// `LIBOS_LOG_NVLOG_BUFFER_FLAG_PACKED_METADATA`: each log entry packs its argument count and
+    /// task id into the word that holds its metadata address.
+    const FLAG_PACKED_METADATA: u32 = 0x1;
+
+    /// Builds the header for a dump of the `task_prefix` task's log, on the GPU that `spec`
+    /// describes, whose GSP runs the firmware with `build_id`.
+    fn new(spec: Spec, build_id: &BuildId, task_prefix: &str) -> Self {
+        let mut header = Self {
+            gpu_arch: spec.chipset.arch() as u32,
+            gpu_impl: u32::from(spec.implementation),
+            version: Self::VERSION,
+            build_id_length: build_id.len(),
+            task_prefix: [0; TASK_NAME_MAX_LENGTH],
+            local_to_global_timer_delta: 0,
+            build_id: *build_id.padded(),
+            flags: Self::FLAG_PACKED_METADATA,
+            reserved: 0,
+        };
+
+        // The last byte stays zero, so that the name is NUL-terminated for the decoder.
+        let prefix = task_prefix.as_bytes();
+        let len = prefix.len().min(TASK_NAME_MAX_LENGTH - 1);
+        header.task_prefix[..len].copy_from_slice(&prefix[..len]);
+
+        header
+    }
+}
+
 /// The logging buffers are byte queues that contain encoded printf-like
 /// messages from GSP-RM.  They need to be decoded by a special application
 /// that can parse the buffers.
@@ -114,7 +193,13 @@ fn init(view: CoherentView<'_, Self>, start: DmaAddress) -> Result<()> {
 /// then pp points to index into the buffer where the next logging entry will
 /// be written. Therefore, the logging data is valid if:
 ///   1 <= pp < sizeof(buffer)/sizeof(u64)
-struct LogBuffer<'a, const NUM_PAGES: usize>(Coherent<'a, [[u8; GSP_PAGE_SIZE]; NUM_PAGES]>);
+struct LogBuffer<'a, const NUM_PAGES: usize> {
+    /// Header that a debugfs dump carries ahead of the data, present when the GSP firmware's
+    /// build ID is known.
+    header: Option<LogBufferHeader>,
+    /// The buffer that GSP-RM logs into.
+    buffer: Coherent<'a, [[u8; GSP_PAGE_SIZE]; NUM_PAGES]>,
+}
 
 /// A log buffer at the default size, [`RM_LOG_BUFFER_NUM_PAGES`] pages.
 ///
@@ -127,20 +212,66 @@ fn init(view: CoherentView<'_, Self>, start: DmaAddress) -> Result<()> {
 type SmallLogBuffer<'a> = LogBuffer<'a, 1>;
 
 impl<'a, const NUM_PAGES: usize> LogBuffer<'a, NUM_PAGES> {
-    /// Creates a new `LogBuffer` mapped on `dev`.
-    fn new(dev: &'a device::Device<device::Bound>) -> Result<Self> {
-        let obj = Self(Coherent::zeroed(dev, GFP_KERNEL)?);
+    /// Creates a new `LogBuffer` mapped on `dev`, whose debugfs dump opens with `header`.
+    fn new(
+        dev: &'a device::Device<device::Bound>,
+        header: Option<LogBufferHeader>,
+    ) -> Result<Self> {
+        let buffer = Coherent::zeroed(dev, GFP_KERNEL)?;
 
-        let start_addr = obj.0.dma_address();
+        let start_addr = buffer.dma_address();
 
         let pte_view = io_project!(
-            obj.0,
+            buffer,
             [build: 0][build: size_of::<u64>()..][build: ..NUM_PAGES * size_of::<u64>()]
         )
         .try_cast::<PteArray<NUM_PAGES>>()?;
         PteArray::init(pte_view, start_addr)?;
 
-        Ok(obj)
+        Ok(Self { header, buffer })
+    }
+}
+
+impl<const NUM_PAGES: usize> debugfs::BinaryWriter for LogBuffer<'_, NUM_PAGES> {
+    fn write_to_slice(
+        &self,
+        writer: &mut UserSliceWriter,
+        offset: &mut file::Offset,
+    ) -> Result<usize> {
+        if offset.is_negative() {
+            return Err(EINVAL);
+        }
+
+        // An offset too large for a `usize` is past the end of the dump.
+        let Ok(offset_val) = usize::try_from(*offset) else {
+            return Ok(0);
+        };
+
+        let header = self
+            .header
+            .as_ref()
+            .map_or(&[][..], |header| header.as_bytes());
+        let total = header.len() + self.buffer.size();
+        if offset_val >= total {
+            return Ok(0);
+        }
+
+        let count = (total - offset_val).min(writer.len());
+        let mut written = 0;
+
+        // The header comes first, and a read that reaches past it continues in the buffer.
+        if let Some(header_rest) = header.get(offset_val..) {
+            written = header_rest.len().min(count);
+            writer.write_slice(&header_rest[..written])?;
+        }
+        if written < count {
+            let buffer_offset = offset_val + written - header.len();
+            writer.write_dma(&self.buffer, buffer_offset, count - written)?;
+            written = count;
+        }
+
+        *offset += i64::try_from(written)?;
+        Ok(written)
     }
 }
 
@@ -164,15 +295,22 @@ impl<'a> LogBuffers<'a> {
     /// Number of log buffers.
     const COUNT: usize = 6;
 
-    /// Allocates the six log buffers, mapped on `dev`.
-    fn new(dev: &'a device::Device<device::Bound>) -> Result<Self> {
+    /// Allocates the six log buffers, mapped on `dev`, and gives each one the dump header for
+    /// the GPU that `spec` describes when `build_id` is known.
+    fn new(
+        dev: &'a device::Device<device::Bound>,
+        spec: Spec,
+        build_id: Option<&BuildId>,
+    ) -> Result<Self> {
+        let header = |task_prefix| build_id.map(|id| LogBufferHeader::new(spec, id, task_prefix));
+
         Ok(Self {
-            loginit: TaskLogBuffer::new(dev)?,
-            logintr: TaskLogBuffer::new(dev)?,
-            logrm: TaskLogBuffer::new(dev)?,
-            logmnoc: TaskLogBuffer::new(dev)?,
-            logroot: SmallLogBuffer::new(dev)?,
-            logrmon: SmallLogBuffer::new(dev)?,
+            loginit: TaskLogBuffer::new(dev, header("INIT"))?,
+            logintr: TaskLogBuffer::new(dev, header("INTR"))?,
+            logrm: TaskLogBuffer::new(dev, header("RM"))?,
+            logmnoc: TaskLogBuffer::new(dev, header("MNOC"))?,
+            logroot: SmallLogBuffer::new(dev, header("ROOT"))?,
+            logrmon: SmallLogBuffer::new(dev, header("RMON"))?,
         })
     }
 
@@ -184,27 +322,27 @@ fn init_arguments(
     ) -> Result {
         libos.init_at(
             0,
-            LibosMemoryRegionInitArgument::new("LOGINIT", &self.loginit.0),
+            LibosMemoryRegionInitArgument::new("LOGINIT", &self.loginit.buffer),
         )?;
         libos.init_at(
             1,
-            LibosMemoryRegionInitArgument::new("LOGINTR", &self.logintr.0),
+            LibosMemoryRegionInitArgument::new("LOGINTR", &self.logintr.buffer),
         )?;
         libos.init_at(
             2,
-            LibosMemoryRegionInitArgument::new("LOGRM", &self.logrm.0),
+            LibosMemoryRegionInitArgument::new("LOGRM", &self.logrm.buffer),
         )?;
         libos.init_at(
             3,
-            LibosMemoryRegionInitArgument::new("LOGMNOC", &self.logmnoc.0),
+            LibosMemoryRegionInitArgument::new("LOGMNOC", &self.logmnoc.buffer),
         )?;
         libos.init_at(
             4,
-            LibosMemoryRegionInitArgument::new("LOGROOT", &self.logroot.0),
+            LibosMemoryRegionInitArgument::new("LOGROOT", &self.logroot.buffer),
         )?;
         libos.init_at(
             5,
-            LibosMemoryRegionInitArgument::new("LOGRMON", &self.logrmon.0),
+            LibosMemoryRegionInitArgument::new("LOGRMON", &self.logrmon.buffer),
         )?;
 
         Ok(())
@@ -212,18 +350,20 @@ fn init_arguments(
 
     /// Exposes each log buffer as a binary file in `dir`, under the lowercase form of its name.
     fn register_debugfs<'data>(&'data self, dir: &debugfs::ScopedDir<'data, '_>) {
-        dir.read_binary_file(c"loginit", &self.loginit.0);
-        dir.read_binary_file(c"logintr", &self.logintr.0);
-        dir.read_binary_file(c"logrm", &self.logrm.0);
-        dir.read_binary_file(c"logmnoc", &self.logmnoc.0);
-        dir.read_binary_file(c"logroot", &self.logroot.0);
-        dir.read_binary_file(c"logrmon", &self.logrmon.0);
+        dir.read_binary_file(c"loginit", &self.loginit);
+        dir.read_binary_file(c"logintr", &self.logintr);
+        dir.read_binary_file(c"logrm", &self.logrm);
+        dir.read_binary_file(c"logmnoc", &self.logmnoc);
+        dir.read_binary_file(c"logroot", &self.logroot);
+        dir.read_binary_file(c"logrmon", &self.logrmon);
     }
 }
 
 /// GSP runtime data.
 #[pin_data]
 pub(crate) struct Gsp<'gsp> {
+    /// The GSP firmware's TLV.
+    gsp_tlv: firmware::Firmware,
     /// Libos arguments.
     pub(crate) libos: Coherent<'gsp, [LibosMemoryRegionInitArgument]>,
     /// Log buffers, optionally exposed via debugfs.
@@ -242,13 +382,25 @@ impl<'gsp> Gsp<'gsp> {
     // Creates an in-place initializer for a `Gsp` manager for `pdev`.
     pub(crate) fn new(
         pdev: &'gsp pci::Device<device::Bound>,
+        spec: Spec,
         bar: Bar0<'gsp>,
     ) -> impl PinInit<Self, Error> + 'gsp {
         pin_init::pin_init_scope(move || {
             let dev = pdev.as_ref();
-            let log_buffers = LogBuffers::new(dev)?;
+
+            let gsp_tlv = request_tlv(dev, spec.chipset, "gsp")?;
+            let build_id = BuildId::from_tlv(&Tlv::new(gsp_tlv.data())?)
+                .inspect_err(|_| {
+                    dev_warn!(
+                        dev,
+                        "no build ID in the GSP firmware TLV, so its log dumps carry no header\n"
+                    )
+                })
+                .ok();
+            let log_buffers = LogBuffers::new(dev, spec, build_id.as_ref())?;
 
             Ok(try_pin_init!(Self {
+                gsp_tlv,
                 cmdq <- Cmdq::new(dev, bar),
                 rm_state_monitor: Coherent::zeroed(dev, GFP_KERNEL)?,
                 rmargs: Coherent::init(
diff --git a/drivers/gpu/nova-core/gsp/boot.rs b/drivers/gpu/nova-core/gsp/boot.rs
index 5da3d134eec7..6f8f330a0297 100644
--- a/drivers/gpu/nova-core/gsp/boot.rs
+++ b/drivers/gpu/nova-core/gsp/boot.rs
@@ -362,7 +362,7 @@ pub(crate) fn boot(
         let dev = pdev.as_ref();
         let hal = super::hal::gsp_hal(chipset);
 
-        let gsp_fw = KBox::pin_init(GspFirmware::new(dev, chipset), GFP_KERNEL)?;
+        let gsp_fw = KBox::pin_init(GspFirmware::new(dev, chipset, &self.gsp_tlv), GFP_KERNEL)?;
 
         let generic_bootloader = hal.generic_bootloader(dev, chipset, gsp_falcon.imem_size())?;
 
-- 
2.55.0


  parent reply	other threads:[~2026-09-18  2:13 UTC|newest]

Thread overview: 5+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2026-09-18  2:12 [PATCH 0/4] gpu: nova-core: debug logging essentials for r000 John Hubbard
2026-09-18  2:12 ` [PATCH 1/4] gpu: nova-core: record the BOOT_42 implementation field in the GPU spec John Hubbard
2026-09-18  2:12 ` John Hubbard [this message]
2026-09-18  2:12 ` [PATCH 3/4] gpu: nova-core: gsp: print GMC command names in debug logs John Hubbard
2026-09-18  2:12 ` [PATCH 4/4] gpu: nova-core: gsp: distinguish GMC events from responses " John Hubbard

Reply instructions:

You may reply publicly to this message via plain-text email
using any one of the following methods:

* Save the following mbox file, import it into your mail client,
  and reply-to-all from there: mbox

  Avoid top-posting and favor interleaved quoting:
  https://en.wikipedia.org/wiki/Posting_style#Interleaved_style

* Reply using the --to, --cc, and --in-reply-to
  switches of git-send-email(1):

  git send-email \
    --in-reply-to=20260918021249.1187837-3-jhubbard@nvidia.com \
    --to=jhubbard@nvidia.com \
    --cc=a.hindborg@kernel.org \
    --cc=acourbot@nvidia.com \
    --cc=airlied@gmail.com \
    --cc=alex.gaynor@gmail.com \
    --cc=aliceryhl@google.com \
    --cc=apopple@nvidia.com \
    --cc=bhelgaas@google.com \
    --cc=bjorn3_gh@protonmail.com \
    --cc=boqun.feng@gmail.com \
    --cc=dakr@kernel.org \
    --cc=ecourtney@nvidia.com \
    --cc=gary@garyguo.net \
    --cc=linux-kernel@vger.kernel.org \
    --cc=lossin@kernel.org \
    --cc=nova-gpu@lists.linux.dev \
    --cc=ojeda@kernel.org \
    --cc=simona@ffwll.ch \
    --cc=tmgross@umich.edu \
    --cc=ttabi@nvidia.com \
    --cc=zhiw@nvidia.com \
    /path/to/YOUR_REPLY

  https://kernel.org/pub/software/scm/git/docs/git-send-email.html

* If your mail client supports setting the In-Reply-To header
  via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox

all inboxes | Powered by JetHome®