Relay Chat Using POSIX File Locks

Here’s what this program does, piece by piece, and how all the parts fit together.

What is it?

A tiny terminal “relay chat” that sends messages between unrelated processes on the same Linux machine by abusing POSIX advisory file locks (fcntl) as a signaling/transport layer. Instead of sockets/pipes/shared memory, it toggles byte-range locks on a shared file to encode bits and messages. The shared file chosen is the namespace handle at "/proc/self/ns/time" (an inode visible system-wide), opened read-only.


The high-level protocol

  • The file is conceptually divided into message blocks of size 0x1000 bytes (4096). Each message occupies one block starting at offsets 0, 0x1000, 0x2000, ….

  • At the start of a block:

  • A short header lock (length MSG_READY == 3) means “this block’s payload is ready”.

  • A separate tracker lock of length 1 at start is used by readers to mark which block they’re currently watching (and to release the previous one).

  • The payload begins at start + MSG_BEGIN_OFFSET (+4). Each byte of the message is encoded over 8 consecutive byte positions in the file:

  • If a bit is 1, the sender sets a read lock (F_RDLCK) on that byte position.

  • If a bit is 0, the sender leaves that byte position unlocked.

  • The receiver scans the block and reconstructs each byte from which positions are locked. The message is a C-string: it ends when the receiver reconstructs a zero byte. The sender processes clear the block after a short TTL (5s): they unlock everything from start + 1 through the end of the block, leaving the 1-byte tracker lock at start behind. That “breadcrumb” helps readers advance to the next block, while the header+payload locks evaporate to make room for future traffic.


Key data structures & helpers

  • chat_state keeps the receiver’s rolling state:

  • buf[512] is where the decoded message lands.

  • next_offset is the next block boundary to watch.

  • last_tracked remembers the previous block’s tracker.

  • offset_tracker is just a flag (“have we set a tracker yet?”).

  • Robust IO wrappers (read_all, write_all) retry on EINTR, bail cleanly otherwise.

  • Terminal helpers:

  • setup_input(1) switches stdin to noncanonical, no-echo, non-blocking mode so keystrokes arrive raw and immediately.

  • setup_input(0) restores normal line input/echo on exit.

  • Lock helpers:

  • set_lock(fd, off, len) and unset_lock place/remove read locks for arbitrary byte ranges.

  • query_lock(fd, &off, &len, &pid) asks the kernel, “if I tried a write lock here, who/what is in the way?” It returns the first conflicting lock (start, length, and owner PID). This is how the program discovers existing read locks (our “bits”).


Receiving messages

check_chatroom(fd, &chat)

  • Find next block: calls query_lock starting at chat->next_offset. If it sees any lock whose start aligns to a 4096 boundary, it treats that as a candidate message block.

  • Set/rotate a tracker:

  • It sets a 1-byte read lock at the block start (set_lock(fd, start, 1)).

  • If it had a previous tracker, it unlocks that older 1-byte lock.

  • It advances chat->next_offset += 0x1000.

  • Wait for “ready”:

  • It polls (every 10ms) by calling query_lock near start + 1. Once the returned lock begins at start and has length == MSG_READY (3), the payload is declared ready. If anything unexpected happens (misaligned locks, changing starts), it yells “interference on the wire” and exits.

  • Decode the payload:

  • recv_str(fd, start + MSG_BEGIN_OFFSET, &chat) reads byte after byte:

  • recv_byte looks from offset to offset + 7 inclusive. It repeatedly calls query_lock to pick up any lock segments, ORs the appropriate bits, and returns the reconstructed byte.

  • Stops when a zero byte is seen (i.e., the next 8 positions have no locks set). If the message expired mid-read (the sender’s TTL has already cleared locks), check_chatroom returns 0 and the caller simply tries again on the next loop iteration.


Sending messages

send_msg(fd, hint, buf, len)

  • Forks a child so the parent keeps the UI snappy.

  • The child calls claim_next_available_msg_block(fd, hint) to reserve a block:

  • It scans blocks starting from hint (usually the receiver’s next_offset) looking for no conflicting locks at the block start.

  • When it finds a candidate, it temporarily locks 2 bytes at the start and runs a cross-process verification dance:

  • It spawns a helper child that calls F_GETLK on that region to learn who owns the lock.

  • If the lock’s pid equals the parent’s PID, the reservation is confirmed; otherwise the parent unlocks and tries the next block.

  • This double-check avoids subtle races where two processes think they reserved the same block.

  • With the block reserved, the child encodes the payload:

  • For each data byte, send_byte sets 8 one-byte locks only where bits are 1.

  • Then it sets the header lock set_lock(fd, start, MSG_READY) to signal “ready.”

  • Sleeps 5 seconds (MSG_TTL_SECONDS) and finally clears everything except the first byte (unset_lock(fd, start + 1, 0xFFF)), leaving the tracker behind.

Null termination detail: When the sender provides len bytes, it doesn’t explicitly send a trailing '\0'. That’s okay: any unsent byte position decodes to all-zero bits, so the receiver will hit a zero byte immediately after the last sent byte and stop. For special system messages (“entered/left the chat”), the code passes sizeof(LITERAL) which includes the '\0' anyway.


The terminal UI & chat loop

chat_loop(fd)

  • Probes the terminal width (ioctl(TIOCGWINSZ)) for nicer prompt rendering.
  • Shows a little animated intro (intro()), then asks your name with a retro “WHO R U?> ” effect.
  • After you type a non-trivial name (or /q to quit), it:
  • Sends “ has entered the chat”.
  • Builds a prefix "> " in an in-memory line buffer.
  • Switches stdin to raw/nonblocking and starts the main loop:
  • Input side: uses poll with a 100ms timeout. Handles backspace (0x7F), Enter (sends the line), and /q (sends “left the chat” then exits).
  • Receive side: calls check_chatroom to decode any new message blocks. On arrival, it prints a timestamp [HH:MM:SS] and the decoded line in blue.
  • Children cleanup: reaps any finished sender processes with waitpid(-1, …, WNOHANG).
  • Prompt redraw: keeps the _> prompt and your current line visible, trimming to terminal width. On exit (or any fatal error), hangup restores the terminal to sane settings.

main()

Opens TIME_NS_PATH (/proc/self/ns/time) read-only and calls chat_loop.Why that file?

  • It’s a real inode that all processes can open without special permission (on a typical system).
  • It supports byte-range locks that are advisory and per-inode (visible across the host).
  • Using a namespace handle is a cute trick to guarantee a single, shared rendezvous point.

Notable design choices & quirks

  • Transport via advisory locks: Locks are visible to all processes and reported via F_GETLK. By asking “who would block my write lock?” the program discovers which byte positions are already read-locked, i.e., which bits are “1”.
  • Bit packing: A 1 bit ⇒ set a one-byte lock; a 0 bit ⇒ no lock. A contiguous run of 1s might appear to receivers as a single longer lock region; the decoder handles that (it ORs the right range of bits).
  • Ready/TTL handshake: The header lock (len == 3) gives a clean “ready” signal; the 5-second TTL prevents stale blocks from lingering forever, but leaves a one-byte breadcrumb so receivers can keep marching forward block-by-block.
  • Race avoidance: The reservation handshake in claim_next_available_msg_block confirms that the process that thinks it holds the reservation actually does (by checking pid), closing a lock-steal race window.
  • Input model: Raw nonblocking input with poll produces a responsive TUI without threads.

Limitations / gotchas

  • Same-host only. This is not a network chat; it’s inter-process signaling on one machine.
  • No authentication. Any local process can read/post/garble messages (or cause “interference on the wire”).
  • Time window: Messages exist for ~5 seconds. If a receiver isn’t polling, it can miss messages (there’s no queue).
  • Throughput: Each 1-bit triggers a fcntl lock op. It’s clever but inefficient for bulk data.
  • Portability: Assumes Linux (/proc, namespace inodes). While fcntl byte-locks are POSIX, the chosen rendezvous file path is Linux-specific.
  • Terminal behavior: If the process crashes before hangup, your terminal might remain noncanonical/no-echo until you run stty sane.

How to compile & run

bash

gcc -Wall -O2 -o relay_chat relay_chat.c ./relay_chat

Open two terminals on the same machine, run it in both, choose different names, and type.
/q quits (and sends a “left the chat” notice).


Possible improvements

  • Add a simple authentication tag inside each payload block (e.g., HMAC over text + timestamp) to filter noise.
  • Replace TIME_NS_PATH with a configurable file (e.g., a world-readable file in /var/lock/…) so admins can confine/disable it.
  • Longer TTL or resend window; or a sequence number + reassembly for basic reliability.
  • Use a single header lock whose length encodes payload length (saves scanning for '0').
  • Add rate limiting or backoff to reduce lock thrash under contention.

TL;DR

This program is a fun demo of “chat over file locks.” It maps bits to read locks on a shared inode, uses a small header lock to say “ready,” and a 5-second TTL to clean up. The terminal loop handles raw keystrokes and draws a tiny TUI. It’s clever, lightweight, and very much a local toy—not a secure or reliable messaging system.