Skip to content
Lesson 9.9Network Messages, Authority, and IPC

Named Pipes and Local Message Framing

Compare byte and message modes in Windows named pipes, then account for framing, bounded reads, connection races, and handle ownership.

Lesson 9.9
Read or listenPrint chapter or book
About 38 minutes

Keyboard shortcuts

?
Open this guide
Alt + B
Show or hide the lesson list
Alt + O
Show or fold the lesson outline
Alt + N
Open or close notes
Alt + H
Toggle semantic colours
Alt + D
Toggle page brightness
Alt + C
Toggle code brightness
← →
Move through a focused code trace
Home End
First or final step in a focused trace
Escape
Close this guide or an open panel

Reader shortcuts pause while you type in a text field.

Cheatsheet for Lesson 9.9Key ideas, reference and questions

A one-page summary of “Named Pipes and Local Message Framing” you can keep beside your notes. Plain Markdown.

Lesson 9.9 — Named Pipes and Local Message Framing: cheatsheet

  • Remember: Message mode remembers write boundaries, but a small read buffer can still return only part of one message.
  • Also useful: A local sender is another program. Validate its lengths and contents just as you would a network sender.

Key ideas

  • restrict who can reach a channel when creating it, then validate each received message.

Review

  • Q: In the lesson's message-mode named pipe, why include a length before the payload? A: The receiver can compare the declared size with the bytes received and its limit
  • Q: ConnectNamedPipe reports ERROR_PIPE_CONNECTED immediately after the server creates its pipe. What does that result mean here? A: A client already connected in the small window before the call
Download cheatsheet (.md)

A named pipe is a local client/server channel

Section titled “A named pipe is a local client/server channel”

Shared memory in Lesson 9.8 gave two programs views of the same bytes, but a concurrent writer needed an additional timing agreement. For one request followed by one reply, a named pipe gives us a connection with an explicit read/write order.

A named pipe server creates a known pipe name and waits. A client opens that name. After they connect, both sides can exchange bytes through ordinary Windows read and write calls.

This resembles a tiny network service, but the lab keeps both programs on the same Windows machine:

named_pipe_lab server
⇅ one bounded request and response
named_pipe_lab client

Named pipes are useful when a supported game plug-in, launcher, capture helper, or your own engine build needs to talk to a separate tool without sharing raw pointers.

One bounded exchange has a clear beginning, two validated messages, and a clear end:

Server creates local pipe
Client opens the pipe
Connection completes
Client sends framed request
Server validates request
Server sends framed reply
Both sides close handles

The pipe transports bytes. Both programs still need to agree on the length field, message kinds, payload encoding, and which requests to reject.

Windows pipes can operate in byte mode or message mode.

  • Byte mode is a stream. One read can receive part of a write or data from several writes.
  • Message mode preserves message boundaries. A read receives one message, provided the buffer is large enough.

The lab selects PIPE_TYPE_MESSAGE when creating the server and PIPE_READMODE_MESSAGE for both ends.

It still adds a four-byte length header. Why use both? Message mode keeps transport boundaries, while the length header validates the application protocol. The receiver can reject a damaged or unexpected frame instead of trusting every byte Windows delivered.

byte 0-3`u32` length, little-endian
byte 4..UTF-8 text, up to 1024 bytes
The pipe frame read_frame validates below. FRAME_BYTES (1028) reserves room for the header plus the maximum payload.

“Local” does not mean “automatically trustworthy.” Another program in the same account or session might know or guess the pipe name.

The receiver checks all of these facts before making a String:

  1. At least four header bytes arrived.
  2. The declared text length is no more than 1024 bytes.
  3. The received frame size equals 4 + declared length.
  4. The text bytes are valid UTF-8.

The first check that fails rejects the frame, and the later ones never run. Try the edge of the limit: 1,028 bytes whose header says 1,024 pass, because the limit is on the text length.

The four checks on one pipe frameA frame is a little-endian u32 text length, then the text. Change what one read returned; the first failed check rejects the frame.

The frame and the checks

What one read returned

Header = 5
byte 0byte 3
05×1
00×256
00×256²
00×256³
Text bytes
5 bytes after the header

Checks, in order

1. bytes_read ≥ 4
9 ≥ 4: the header is complete
2. length ≤ 1,024
5 ≤ 1,024
3. bytes_read = 4 + length
9 = 4 + 5
4. valid UTF-8
valid: the text can become a String
Result
Accept: return the 5-byte text

All four checks pass: 9 bytes arrived, the header says 5, and 9 = 4 + 5. The limit applies to the text length, so 5 ≤ 1,024 is allowed.

The server accepts one client, handles one request, sends one fixed reply, and exits. Small limits make the behavior easy to reason about.

This byte-stream counterexample shows why a single successful read need not contain a complete payload. The Windows lab below chooses message mode and still checks lengths.

One byte-stream message can arrive in pieces. This step-by-step trace needs JavaScript. Here is the code and where the run ends.

promised_payload_bytes = 8
copied = read(first_fragment)
remaining = promised_payload_bytes - copied
read again until remaining == 0
parse only the complete bounded payload
report read calls and assembled bytes

Result: eight bytes assembled in 2 read call(s). A disconnected or malformed sender must fail instead of inventing missing bytes.

Final values: first = 3; expected = 8; copied = 8; read_calls = 2; remaining = 0; parsed = true.

Read every step of the lesson’s example
  1. Step 1, line 1. Start with the selected inputs. Follow the highlighted operation and the changing state below.

    first = 3.

  2. Step 2, line 1. The length prefix already promised eight payload bytes.

    first = 3; expected = 8; copied = 0; read_calls = 0.

  3. Step 3, line 2. The first successful read supplies 3 bytes.

    first = 3; expected = 8; copied = 3; read_calls = 1.

  4. Step 4, line 3. Keep track of the exact unfilled amount.

    first = 3; expected = 8; copied = 3; read_calls = 1; remaining = 5.

  5. Step 5, line 4. In this illustration the next read fills the remaining 5 bytes. Real code loops until full or an error.

    first = 3; expected = 8; copied = 8; read_calls = 2; remaining = 0.

  6. Step 6, line 5. The parser receives the complete eight-byte payload, not its first fragment.

    first = 3; expected = 8; copied = 8; read_calls = 2; remaining = 0; parsed = true.

  7. Step 7, line 6. Result: eight bytes assembled in 2 read call(s). A disconnected or malformed sender must fail instead of inventing missing bytes.

    first = 3; expected = 8; copied = 8; read_calls = 2; remaining = 0; parsed = true.

Named pipes can support remote clients. This lab includes PIPE_REJECT_REMOTE_CLIENTS, so Windows rejects them automatically.

This original creation helper chooses message mode and duplex access, but does not explicitly reject remote clients. This helper extracts the creation step from the complete lab below, using its pipe name and FRAME_BYTES buffer limit; OwnedHandle closes the created pipe. Accepting and parsing the request happens later.

Before: create a pipe without rejecting remote clients
fn create_server_pipe() -> anyhow::Result<OwnedHandle> {
let mode = PIPE_TYPE_MESSAGE | PIPE_READMODE_MESSAGE | PIPE_WAIT; // No remote check.
// SAFETY: The pipe name is static and terminated; no security pointer is supplied.
let pipe = unsafe {
CreateNamedPipeW(
windows::core::w!(r"\\.\pipe\gha-local-game-ipc-v1"), PIPE_ACCESS_DUPLEX, mode,
1, FRAME_BYTES as u32, FRAME_BYTES as u32, 0, None,
)
};
let pipe = OwnedHandle::from_raw(pipe)?;
Ok(pipe)
}

Add the local-only capability boundary to the creation mode. It rejects remote clients at the Windows pipe boundary; it does not replace the normal access check for local clients.

Improved code

fn create_server_pipe() -> anyhow::Result<OwnedHandle> {
let mode = PIPE_TYPE_MESSAGE
| PIPE_READMODE_MESSAGE
| PIPE_WAIT
| PIPE_REJECT_REMOTE_CLIENTS;
// SAFETY: The pipe name is static and terminated; no security pointer is supplied.
let pipe = unsafe {
CreateNamedPipeW(
windows::core::w!(r"\\.\pipe\gha-local-game-ipc-v1"), PIPE_ACCESS_DUPLEX, mode,
1, FRAME_BYTES as u32, FRAME_BYTES as u32, 0, None,
)
};
let pipe = OwnedHandle::from_raw(pipe)?;
Ok(pipe)
}

Show the diff

fn create_server_pipe() -> anyhow::Result<OwnedHandle> {
let mode = PIPE_TYPE_MESSAGE | PIPE_READMODE_MESSAGE | PIPE_WAIT; // No remote check.
let mode = PIPE_TYPE_MESSAGE
| PIPE_READMODE_MESSAGE
| PIPE_WAIT
| PIPE_REJECT_REMOTE_CLIENTS;
// SAFETY: The pipe name is static and terminated; no security pointer is supplied.
let pipe = unsafe {
CreateNamedPipeW(
windows::core::w!(r"\\.\pipe\gha-local-game-ipc-v1"), PIPE_ACCESS_DUPLEX, mode,
1, FRAME_BYTES as u32, FRAME_BYTES as u32, 0, None,
)
};
let pipe = OwnedHandle::from_raw(pipe)?;
Ok(pipe)
}

Takeaway: restrict who can reach a channel when creating it, then validate each received message.

The program does not call ImpersonateNamedPipeClient, duplicate tokens, start a process as another identity, or change privileges. Those operations solve different identity and access-control problems; they are not part of this message channel.

Complete lab source: named_pipe_lab.rs

named_pipe_lab.rs

windows-labs/src/bin/ · Rust, 161 lines

Show the codeHide the code
#[cfg(windows)]
mod app {
    use anyhow::{Context, ensure};
    use gha_windows_labs::OwnedHandle;
    use windows::{
        Win32::{
            Foundation::{ERROR_PIPE_CONNECTED, GENERIC_READ, GENERIC_WRITE},
            Storage::FileSystem::{
                CreateFileW, FILE_ATTRIBUTE_NORMAL, FILE_SHARE_MODE, OPEN_EXISTING,
                PIPE_ACCESS_DUPLEX, ReadFile, WriteFile,
            },
            System::Pipes::{
                ConnectNamedPipe, CreateNamedPipeW, PIPE_READMODE_MESSAGE,
                PIPE_REJECT_REMOTE_CLIENTS, PIPE_TYPE_MESSAGE, PIPE_WAIT, SetNamedPipeHandleState,
            },
        },
        core::w,
    };

    const MAX_TEXT_BYTES: usize = 1_024;
    const FRAME_BYTES: usize = MAX_TEXT_BYTES + 4;

    fn frame(text: &str) -> anyhow::Result<Vec<u8>> {
        ensure!(
            text.len() <= MAX_TEXT_BYTES,
            "message is longer than {MAX_TEXT_BYTES} UTF-8 bytes"
        );
        let mut bytes = Vec::with_capacity(text.len() + 4);
        bytes.extend_from_slice(&(text.len() as u32).to_le_bytes());
        bytes.extend_from_slice(text.as_bytes());
        Ok(bytes)
    }

    fn read_frame(handle: &OwnedHandle) -> anyhow::Result<String> {
        let mut buffer = [0_u8; FRAME_BYTES];
        let mut bytes_read = 0_u32;
        // SAFETY: buffer is writable, bytes_read is a valid output pointer,
        // and this synchronous lab does not supply an OVERLAPPED structure.
        unsafe { ReadFile(handle.raw(), Some(&mut buffer), Some(&mut bytes_read), None) }?;
        let bytes_read = bytes_read as usize;
        ensure!(
            bytes_read >= 4,
            "pipe message did not contain a length header"
        );
        let declared = u32::from_le_bytes(buffer[..4].try_into()?) as usize;
        ensure!(
            declared <= MAX_TEXT_BYTES,
            "pipe length header is too large"
        );
        ensure!(
            bytes_read == declared + 4,
            "pipe frame length did not match its header"
        );
        Ok(std::str::from_utf8(&buffer[4..bytes_read])
            .context("pipe message was not valid UTF-8")?
            .to_owned())
    }

    fn write_frame(handle: &OwnedHandle, text: &str) -> anyhow::Result<()> {
        let bytes = frame(text)?;
        let mut bytes_written = 0_u32;
        // SAFETY: bytes remains readable during the synchronous call and
        // bytes_written is a valid output pointer.
        unsafe { WriteFile(handle.raw(), Some(&bytes), Some(&mut bytes_written), None) }?;
        ensure!(
            bytes_written as usize == bytes.len(),
            "Windows wrote only part of the pipe frame"
        );
        Ok(())
    }

    fn server() -> anyhow::Result<()> {
        let mode =
            PIPE_TYPE_MESSAGE | PIPE_READMODE_MESSAGE | PIPE_WAIT | PIPE_REJECT_REMOTE_CLIENTS;
        // SAFETY: the pipe name is static. The server allows one synchronous,
        // message-mode connection and supplies no custom security pointer.
        let pipe = unsafe {
            CreateNamedPipeW(
                w!(r"\\.\pipe\gha-local-game-ipc-v1"),
                PIPE_ACCESS_DUPLEX,
                mode,
                1,
                FRAME_BYTES as u32,
                FRAME_BYTES as u32,
                0,
                None,
            )
        };
        let pipe = OwnedHandle::from_raw(pipe)?;
        println!("Server is waiting for one local client...");

        // A client can connect between CreateNamedPipeW and ConnectNamedPipe.
        // In that harmless race, Windows reports ERROR_PIPE_CONNECTED.
        // SAFETY: pipe owns a valid server-pipe handle and this synchronous
        // call deliberately supplies no OVERLAPPED structure.
        match unsafe { ConnectNamedPipe(pipe.raw(), None) } {
            Ok(()) => {}
            Err(error) if error.code() == ERROR_PIPE_CONNECTED.to_hresult() => {}
            Err(error) => return Err(error.into()),
        }

        let message = read_frame(&pipe)?;
        println!("Client said: {message}");
        write_frame(&pipe, "ACK: the local game-tool message arrived")?;
        println!("Replied once; the server will now close the pipe.");
        Ok(())
    }

    fn client(message: &str) -> anyhow::Result<()> {
        // SAFETY: the path is static. This opens only the local course pipe,
        // requests read/write data access, and does not inherit the handle.
        let pipe = unsafe {
            CreateFileW(
                w!(r"\\.\pipe\gha-local-game-ipc-v1"),
                GENERIC_READ.0 | GENERIC_WRITE.0,
                FILE_SHARE_MODE(0),
                None,
                OPEN_EXISTING,
                FILE_ATTRIBUTE_NORMAL,
                None,
            )
        }
        .context("start `named_pipe_lab server` in another window first")?;
        let pipe = OwnedHandle::from_raw(pipe)?;
        let read_mode = PIPE_READMODE_MESSAGE;
        // SAFETY: pipe is a connected named-pipe handle and read_mode remains
        // valid for the duration of the call.
        unsafe { SetNamedPipeHandleState(pipe.raw(), Some(&read_mode), None, None) }?;

        write_frame(&pipe, message)?;
        println!("Server replied: {}", read_frame(&pipe)?);
        Ok(())
    }

    pub fn run() -> anyhow::Result<()> {
        let mut arguments = std::env::args().skip(1);
        match arguments.next().as_deref() {
            Some("server") => server(),
            Some("client") => {
                let message = arguments.collect::<Vec<_>>().join(" ");
                let message = if message.is_empty() {
                    "Wesnoth helper connected"
                } else {
                    &message
                };
                client(message)
            }
            _ => anyhow::bail!("usage: named_pipe_lab server | named_pipe_lab client <message>"),
        }
    }
}

#[cfg(windows)]
fn main() -> anyhow::Result<()> {
    app::run()
}

#[cfg(not(windows))]
fn main() {
    eprintln!("This local named-pipe lab must run on Windows.");
}
#[cfg(windows)]
mod app {
use anyhow::{Context, ensure};
use gha_windows_labs::OwnedHandle;
use windows::{
Win32::{
Foundation::{ERROR_PIPE_CONNECTED, GENERIC_READ, GENERIC_WRITE},
Storage::FileSystem::{
CreateFileW, FILE_ATTRIBUTE_NORMAL, FILE_SHARE_MODE,
OPEN_EXISTING, PIPE_ACCESS_DUPLEX, ReadFile, WriteFile,
},
System::Pipes::{
ConnectNamedPipe, CreateNamedPipeW, PIPE_READMODE_MESSAGE,
PIPE_REJECT_REMOTE_CLIENTS, PIPE_TYPE_MESSAGE, PIPE_WAIT,
SetNamedPipeHandleState,
},
},
core::w,
};
const MAX_TEXT_BYTES: usize = 1_024;
const FRAME_BYTES: usize = MAX_TEXT_BYTES + 4;
fn frame(text: &str) -> anyhow::Result<Vec<u8>> {
// 📏 `str::len` is UTF-8 bytes, exactly what the wire header measures;
// visible character count would be wrong for emoji or accented text.
ensure!(text.len() <= MAX_TEXT_BYTES,
"message is longer than {MAX_TEXT_BYTES} UTF-8 bytes");
let mut bytes = Vec::with_capacity(text.len() + 4);
// 📦 Prefixing the length makes the receiver reject truncated or joined
// data instead of guessing where the message ends.
bytes.extend_from_slice(&(text.len() as u32).to_le_bytes());
bytes.extend_from_slice(text.as_bytes());
Ok(bytes)
}
fn read_frame(handle: &OwnedHandle) -> anyhow::Result<String> {
let mut buffer = [0_u8; FRAME_BYTES];
let mut bytes_read = 0_u32;
// 🛡️ SAFETY: buffer is writable, bytes_read is a valid output pointer,
// and this synchronous lab does not supply an OVERLAPPED structure.
unsafe {
ReadFile(handle.raw(), Some(&mut buffer), Some(&mut bytes_read), None)
}?;
let bytes_read = bytes_read as usize;
// 🛡️ Validate in dependency order: header exists, declared payload fits,
// and the one message-mode read agrees exactly with that declaration.
ensure!(bytes_read >= 4, "pipe message did not contain a length header");
let declared = u32::from_le_bytes(buffer[..4].try_into()?) as usize;
ensure!(declared <= MAX_TEXT_BYTES, "pipe length header is too large");
ensure!(bytes_read == declared + 4,
"pipe frame length did not match its header");
// ✅ Only the declared, completely received payload is decoded as UTF-8.
Ok(std::str::from_utf8(&buffer[4..bytes_read])
.context("pipe message was not valid UTF-8")?
.to_owned())
}
fn write_frame(handle: &OwnedHandle, text: &str) -> anyhow::Result<()> {
let bytes = frame(text)?;
let mut bytes_written = 0_u32;
// 📦 SAFETY: bytes remains readable during the synchronous call and
// bytes_written is a valid output pointer.
unsafe {
WriteFile(handle.raw(), Some(&bytes), Some(&mut bytes_written), None)
}?;
// ⚠️ A successful synchronous call still reports how much it wrote;
// accepting a short write would send a frame whose header lies.
ensure!(bytes_written as usize == bytes.len(),
"Windows wrote only part of the pipe frame");
Ok(())
}
fn server() -> anyhow::Result<()> {
let mode = PIPE_TYPE_MESSAGE
| PIPE_READMODE_MESSAGE
| PIPE_WAIT
| PIPE_REJECT_REMOTE_CLIENTS;
// 🔒 SAFETY: the pipe name is static. The server allows one synchronous,
// message-mode connection and supplies no custom security pointer.
let pipe = unsafe {
CreateNamedPipeW(
w!(r"\\.\pipe\gha-local-game-ipc-v1"),
PIPE_ACCESS_DUPLEX,
mode,
1,
FRAME_BYTES as u32,
FRAME_BYTES as u32,
0,
None,
)
};
let pipe = OwnedHandle::from_raw(pipe)?;
println!("Server is waiting for one local client...");
// ⚠️ A client can connect between CreateNamedPipeW and ConnectNamedPipe.
// In that harmless race, Windows reports ERROR_PIPE_CONNECTED.
// 🛡️ SAFETY: pipe owns a valid server-pipe handle and this synchronous
// call deliberately supplies no OVERLAPPED structure.
match unsafe { ConnectNamedPipe(pipe.raw(), None) } {
Ok(()) => {}
Err(error) if error.code() == ERROR_PIPE_CONNECTED.to_hresult() => {}
Err(error) => return Err(error.into()),
}
let message = read_frame(&pipe)?;
println!("Client said: {message}");
write_frame(&pipe, "ACK: the local game-tool message arrived")?;
println!("Replied once; the server will now close the pipe.");
Ok(())
}
fn client(message: &str) -> anyhow::Result<()> {
// 🧭 SAFETY: the path is static. This opens only the local course pipe,
// requests read/write data access, and does not inherit the handle.
let pipe = unsafe {
CreateFileW(
w!(r"\\.\pipe\gha-local-game-ipc-v1"),
GENERIC_READ.0 | GENERIC_WRITE.0,
FILE_SHARE_MODE(0),
None,
OPEN_EXISTING,
FILE_ATTRIBUTE_NORMAL,
None,
)
}
.context("start `named_pipe_lab server` in another window first")?;
let pipe = OwnedHandle::from_raw(pipe)?;
let read_mode = PIPE_READMODE_MESSAGE;
// ✅ SAFETY: pipe is a connected named-pipe handle and read_mode remains
// valid for the duration of the call.
unsafe {
SetNamedPipeHandleState(pipe.raw(), Some(&read_mode), None, None)
}?;
write_frame(&pipe, message)?;
println!("Server replied: {}", read_frame(&pipe)?);
Ok(())
}
pub fn run() -> anyhow::Result<()> {
let mut arguments = std::env::args().skip(1);
match arguments.next().as_deref() {
Some("server") => server(),
Some("client") => {
let message = arguments.collect::<Vec<_>>().join(" ");
let message = if message.is_empty() {
"Wesnoth helper connected"
} else {
&message
};
client(message)
}
_ => anyhow::bail!(
"usage: named_pipe_lab server | named_pipe_lab client <message>"
),
}
}
}
#[cfg(windows)]
fn main() -> anyhow::Result<()> {
app::run()
}
#[cfg(not(windows))]
fn main() {
eprintln!("This local named-pipe lab must run on Windows.");
}

A client can open the pipe after CreateNamedPipeW creates it but just before the server calls ConnectNamedPipe. Windows then returns ERROR_PIPE_CONNECTED. That does not mean the connection failed—it means the client already won the race.

The match arm accepts only that documented condition and returns every other error:

ClientServerClientServerthe client connects here,before ConnectNamedPipe runsCreateNamedPipeWCreateFileW opens the pipeConnectNamedPipereturns ERROR_PIPE_CONNECTED

The client may connect in the narrow interval before the server calls ConnectNamedPipe. In that case Windows reports ERROR_PIPE_CONNECTED, which means the connection already exists rather than that it failed. The match below accepts both successful outcomes and propagates other errors:

match unsafe { ConnectNamedPipe(pipe.raw(), None) } {
Ok(()) => {}
Err(error) if error.code() == ERROR_PIPE_CONNECTED.to_hresult() => {}
Err(error) => return Err(error.into()),
}
Windows VM

In window one:

Terminal window
cd windows-labs
cargo run --release --target i686-pc-windows-msvc `
--bin named_pipe_lab -- server

In window two:

Terminal window
cd windows-labs
cargo run --release --target i686-pc-windows-msvc `
--bin named_pipe_lab -- client "Urban Terror local replay marker"

The client prints the acknowledgement and both programs exit. Start the server again for another message.

A larger tool could add a version number and an enum of allowed messages such as TurnStarted, MapLoaded, or PlayerPositionFromSupportedMod. It should authenticate sensitive clients, set an explicit security descriptor, rate-limit messages, keep bounded queues, and separate parsing from actions.

Do not accept a command string and pass it to PowerShell or cmd.exe. Define a small message enum and reject unknown variants. Data should select one defined action, not become code.

The buildable source is named_pipe_lab.rs.

References: named-pipe operations, CreateNamedPipeW, ConnectNamedPipe, and SetNamedPipeHandleState.

Knowledge check

Separate pipe messages

In the lesson's message-mode named pipe, why include a length before the payload?

Finished Lesson 9.9?

Mark it done and the lesson list shows a beside it. A chapter whose lessons are all done is marked done too. Progress is saved in this browser only.

Chapter 9: 10 lessons

This book grew out of the original Game Hacking Academy by attilathedud and has been rewritten as a beginner-first course. Like the original repository, its source is published under the Apache License 2.0.