Case Study 1: Memory Management — What Can Go Wrong

Overview

This case study presents three realistic memory bugs — a leak in a loop, a dangling pointer after Dispose, and a nil dereference on an empty list — each formatted as a debugging exercise. For each bug, you will see the code, the symptom, and then a guided diagnosis leading to the fix. These are the bugs that haunt every systems programmer. Learning to recognize their patterns here will save you hours of debugging in the future.


Bug 1: The Slow Death — A Memory Leak in a Loop

The Scenario

A student wrote a program that reads temperature readings from a sensor (simulated as user input) and stores each reading in a dynamically allocated record. The program runs in a loop, processing one reading at a time. After several hours of continuous operation, the program crashes with an "out of memory" error.

The Code

program TemperatureMonitor;
type
  PReading = ^TReading;
  TReading = record
    Temperature: Real;
    Timestamp: Integer;
  end;

var
  Current: PReading;
  Temp: Real;
  Time: Integer;
begin
  Time := 0;
  WriteLn('Temperature Monitor (enter negative to quit)');

  repeat
    Write('Reading: ');
    ReadLn(Temp);

    if Temp >= 0 then
    begin
      New(Current);                { Allocate a new record }
      Current^.Temperature := Temp;
      Current^.Timestamp := Time;
      Inc(Time);

      { Process the reading }
      if Current^.Temperature > 100.0 then
        WriteLn('WARNING: High temperature at time ', Current^.Timestamp)
      else
        WriteLn('Normal: ', Current^.Temperature:0:1, ' at time ',
                Current^.Timestamp);

      { BUG: Where is Dispose? }
    end;
  until Temp < 0;

  WriteLn('Monitor stopped.');
end.

The Symptom

The program works correctly for any single run of reasonable length. All readings are processed and warnings are issued appropriately. But if you monitor memory usage (using Task Manager or a similar tool), you see the program's memory consumption grow steadily — about 16 bytes per reading (the size of a TReading record plus heap manager overhead). After 1 million readings, the program has leaked roughly 16 MB.

The Diagnosis

Let us trace the loop:

  1. Iteration 1: New(Current) allocates block A on the heap. Current points to A. The reading is processed.
  2. Iteration 2: New(Current) allocates block B on the heap. Current now points to B. Block A is still allocated, but no pointer refers to it. It is leaked.
  3. Iteration 3: New(Current) allocates block C. Block B is leaked.
  4. ...and so on.

Each iteration allocates a new block and overwrites the only pointer to the previous block. The previous block becomes unreachable — it cannot be freed because no pointer refers to it.

The Fix

The fix is straightforward: call Dispose(Current) at the end of each iteration, after processing is complete:

      { Process the reading }
      if Current^.Temperature > 100.0 then
        WriteLn('WARNING: High temperature at time ', Current^.Timestamp)
      else
        WriteLn('Normal: ', Current^.Temperature:0:1, ' at time ',
                Current^.Timestamp);

      Dispose(Current);         { FREE the record after processing }
      Current := nil;           { Defensive: mark as freed }
    end;

Alternatively, the programmer could avoid dynamic allocation entirely — a local variable on the stack would suffice here, since the record is only needed within one iteration:

var
  Reading: TReading;    { Stack-allocated — no leak possible }
begin
  { ... }
  Reading.Temperature := Temp;
  Reading.Timestamp := Time;
  { Process Reading — no New, no Dispose needed }

The Lesson

If you allocate memory in a loop, you must free it in the same loop (or accumulate the allocations in a data structure that will be freed later). Every iteration that calls New without a matching Dispose leaks memory. Ask yourself: "At the end of this iteration, does every heap block I allocated have either (a) a pointer still referencing it, or (b) been freed?"


Bug 2: The Ghost — A Dangling Pointer After Dispose

The Scenario

A programmer builds a simple student record system. Two modules need access to the current student's record: the display module and the grading module. The display module keeps a pointer to the record for refreshing the screen. The grading module also has a pointer to the same record. When the grading module finishes, it frees the record. The display module then tries to refresh — and crashes (or worse, displays corrupted data).

The Code

program StudentSystem;
type
  PStudent = ^TStudent;
  TStudent = record
    Name: string[50];
    Grade: Char;
  end;

var
  DisplayPtr: PStudent;
  GradePtr: PStudent;

procedure CreateStudent;
begin
  New(DisplayPtr);
  DisplayPtr^.Name := 'Alice Johnson';
  DisplayPtr^.Grade := 'B';
  GradePtr := DisplayPtr;     { Both pointers point to the same record }
end;

procedure UpdateGrade;
begin
  GradePtr^.Grade := 'A';
  WriteLn('Grade updated to A.');
  Dispose(GradePtr);           { Free the record }
  GradePtr := nil;             { GradePtr is now safe }
  { But DisplayPtr still points to the freed memory! }
end;

procedure RefreshDisplay;
begin
  WriteLn('Displaying: ', DisplayPtr^.Name, ' — Grade: ',
          DisplayPtr^.Grade);
  { BUG: DisplayPtr is a dangling pointer! }
end;

begin
  CreateStudent;
  WriteLn('Initial display:');
  RefreshDisplay;              { Works fine }

  WriteLn;
  UpdateGrade;                 { Frees the record }

  WriteLn;
  WriteLn('After grade update:');
  RefreshDisplay;              { BUG: dangling pointer dereference }
end.

The Symptom

The program might appear to work. It might print:

Initial display:
Displaying: Alice Johnson — Grade: B

Grade updated to A.

After grade update:
Displaying: Alice Johnson — Grade: A

The second RefreshDisplay call accesses freed memory, but since the memory has not been reused yet, the old data is still there. This is the insidious nature of dangling pointers: they often appear to work, masking the bug until the program is under heavy load or running for a long time. In a more complex program, the heap manager might have reused that memory block for a completely different record, and the display would show garbage.

The Diagnosis

The problem is an alias — two pointers (DisplayPtr and GradePtr) refer to the same heap block. When UpdateGrade disposes through GradePtr, it correctly sets GradePtr := nil. But it has no way of knowing that DisplayPtr also points to the same block. DisplayPtr is now dangling.

The Fix

There are several approaches:

Approach 1: Centralized ownership. Designate one pointer as the owner. Only the owner can free. Others must check with the owner before using:

var
  StudentRecord: PStudent;    { The one true owner }

procedure UpdateGrade;
begin
  StudentRecord^.Grade := 'A';
  WriteLn('Grade updated to A.');
  { Do NOT free here — owner is responsible for lifetime }
end;

Approach 2: Nil propagation. When freeing, set ALL known aliases to nil:

procedure FreeStudent;
begin
  Dispose(DisplayPtr);
  DisplayPtr := nil;
  GradePtr := nil;       { Must know about all aliases }
end;

Approach 3: Nil-check before use.

procedure RefreshDisplay;
begin
  if DisplayPtr <> nil then
    WriteLn('Displaying: ', DisplayPtr^.Name, ' — Grade: ',
            DisplayPtr^.Grade)
  else
    WriteLn('No student record to display.');
end;

Approach 1 is the most robust — it prevents the problem structurally. Approach 2 is fragile (you must know every alias). Approach 3 is a safety net but does not fix the root cause.

The Lesson

When multiple pointers reference the same heap block, freeing through one pointer invalidates all the others. The solution is clear ownership: decide which pointer owns the data, and ensure all other pointers are treated as temporary borrowers that never call Dispose.


Bug 3: The Empty List Crash — Nil Dereference

The Scenario

A programmer writes a function to find the largest expense in the PennyWise linked list. The function works perfectly when the list has data, but the program crashes when the user tries to find the maximum before entering any expenses.

The Code

function MaxExpense(Head: PExpenseNode): Real;
var
  Current: PExpenseNode;
  MaxVal: Real;
begin
  MaxVal := Head^.Data.Amount;    { BUG: What if Head is nil? }
  Current := Head^.Next;

  while Current <> nil do
  begin
    if Current^.Data.Amount > MaxVal then
      MaxVal := Current^.Data.Amount;
    Current := Current^.Next;
  end;

  MaxExpense := MaxVal;
end;

The Symptom

When called on a non-empty list, the function works correctly. When called with Head = nil (an empty list), the program crashes with a "runtime error 216" (Access Violation / SIGSEGV) at the line MaxVal := Head^.Data.Amount.

The Diagnosis

The programmer assumed the list would always have at least one element. The very first line dereferences Head without checking if it is nil. When the list is empty, Head is nil, and nil^ is an illegal memory access.

This is a common oversight. It is easy to write code that works for the "normal" case and forget the edge cases. For linked lists, the empty list (Head = nil) is the most important edge case.

The Fix

Add a nil check at the beginning:

function MaxExpense(Head: PExpenseNode): Real;
var
  Current: PExpenseNode;
  MaxVal: Real;
begin
  if Head = nil then
  begin
    WriteLn('Error: cannot find maximum of an empty list.');
    MaxExpense := 0.0;       { Return a sentinel value }
    Exit;
  end;

  MaxVal := Head^.Data.Amount;
  Current := Head^.Next;

  while Current <> nil do
  begin
    if Current^.Data.Amount > MaxVal then
      MaxVal := Current^.Data.Amount;
    Current := Current^.Next;
  end;

  MaxExpense := MaxVal;
end;

An even more robust approach uses a Boolean var parameter to indicate success or failure:

function MaxExpense(Head: PExpenseNode; var Found: Boolean): Real;
begin
  if Head = nil then
  begin
    Found := False;
    MaxExpense := 0.0;
    Exit;
  end;

  Found := True;
  { ... rest of function ... }
end;

The Lesson

Every function that receives a pointer parameter should consider the possibility that the pointer is nil. For linked list functions, this means handling the empty list. Develop the habit of writing the nil-check case first, before you write the main logic.


Summary of Debugging Patterns

Bug Type Symptom Cause Prevention
Memory leak Slowly growing memory usage New without matching Dispose Match every allocation with a deallocation; audit loops
Dangling pointer Crash or corrupted data (intermittent) Using a pointer after Dispose Set to nil after Dispose; ownership discipline
Nil dereference Immediate crash (SIGSEGV) Dereferencing a nil pointer Check p <> nil before p^; handle empty lists
Double free Crash or heap corruption Dispose called twice on same block Set to nil after Dispose; single ownership

These four patterns account for the vast majority of pointer bugs in Pascal and C programs. Learn to recognize them by their symptoms, and you will save yourself countless hours of debugging.