DEV Community

Fedor Pasynkov
Fedor Pasynkov

Posted on • Originally published at fedor-pasynkov.ru

Debugging a GNOME Shell layout collapse: how a 4,294,967,296 px preferred height collapsed St.ScrollView to 1 px

Debugging a GNOME Shell layout collapse: how a 4,294,967,296 px preferred height collapsed St.ScrollView to 1 px

Tested Environment:

  • OS: Ubuntu 22.04.5 LTS (Linux x86_64)
  • Runtime: GNOME Shell 42.9 (GJS 1.72.4), Mutter 42.0+
  • Session: Wayland & X11

Imagine this scenario: you are developing an extension or desktop UI component for GNOME Shell using JavaScript (GJS). The extension loads cleanly with zero errors in journalctl. All GObjects are instantiated, icons are built, but on the screen... absolute void.

When you log the actor geometry to the console, you see an unusual output:

appsScroll=1
appsBox=1
status=4294967040
Enter fullscreen mode Exit fullscreen mode

The parent St.ScrollView container has collapsed to a height of 1px, turning your interface into an invisible line across the screen. Meanwhile, its child grid actor claims its natural height is... 4,294,967,296 pixels (over 4 million kilometers — more than ten times the distance from Earth to the Moon!).

In this article, we will walk through the debugging investigation: from recursively traversing the Clutter Scene Graph to dissecting geometry allocation clamping and floating-point (gfloat) step boundaries near $2^{32}$.


1. Diagnostics: Locating the "Astronomical" Actor

When an element vanishes in Clutter/St, the initial hypothesis is usually an opacity issue (opacity = 0), visibility toggle (visible = false), or a z-index clipping problem. However, checking actor state confirms that elements exist in the Scene Graph.

To identify which container breaks layout calculations, we write a recursive helper function to inspect the Clutter Actor Tree:

function inspectActorTree(actor, depth = 0) {
    // In get_preferred_height(-1), -1 represents an unconstrained width query (for_width = -1)
    let [minH, natH] = actor.get_preferred_height(-1);
    let alloc = actor.get_allocation_box();
    let name = actor.get_name() || actor.constructor.name;
    console.log(`${' '.repeat(depth * 2)}${name}: minH=${minH}, natH=${natH}, allocH=${alloc.get_height()}`);

    actor.get_children().forEach(child => inspectActorTree(child, depth + 1));
}
Enter fullscreen mode Exit fullscreen mode

Running this inspection tool outputs the critical clue:

StBoxLayout (parent): minH=0, natH=4294967296, allocH=600
  StScrollView: minH=1, natH=4294967296, allocH=1   <-- Root cause container!
    ChildGridActor: minH=0, natH=4294967296, allocH=0
Enter fullscreen mode Exit fullscreen mode

The child grid ChildGridActor reports a natural height natH = 4294967296. Seeing this astronomical request, the parent St.ScrollView collapses to 1px.


2. Observed Facts & Evidence Breakdown

Let's separate confirmed observations from logical deductions.

1. The Input Parameter -1 (for_width)

In Clutter API, clutter_actor_get_preferred_height(actor, -1, &min_h, &nat_h) uses -1 as an unconstrained query flag (querying height with no width constraint). If a child actor lacks explicit min-width/min-height constraints and encounters conflicting alignment flags, preferred size calculations can return invalid or sentinel values.

2. Why Does the Container Collapse to Exactly 1px?

In Clutter's layout allocation engine (clutter_actor_allocate), allocation box heights are clamped to minimum bounds:
$$\text{allocated_height} = \max(\text{min_height}, \text{calculated_space})$$

  1. StScrollView defines a minimum preferred height minH = 1px (its baseline padding/border constraint).
  2. When parent ClutterBoxLayout attempted to distribute available monitor height across siblings with an astronomical request ($4,294,967,296\text{ px}$), remaining space became zero or negative.
  3. Clutter clamped the child allocation box to its declared minimum size — exactly 1px.

3. gfloat Precision at $2^{32}$ and Logged Values

In Clutter C layout math (clutter/clutter-box-layout.c), coordinates and sizes pass through gfloat (IEEE 754 float32).

In the $[2^{31}, 2^{32})$ range, single-precision floats have a representability step (ULP) of 256:

  • $2^{32} = 4,294,967,296$
  • $2^{32} - 256 = 4,294,967,040$

The logged values — 4,294,967,296 and 4,294,967,040 — match representable gfloat float32 numbers near the $2^{32}$ limit.

Investigation Boundary: These values were recorded in a GJS geometry inspection dump. The exact C call chain inside Mutter leading to the initial $2^{32}$ overflow remains a working hypothesis requiring deeper gdb source tracing.


3. Disproven Workarounds (Failed Approaches)

Before finding the correct fix, 3 plausible workarounds were tested, each creating regressions:

❌ Failed Approach #1: Hardcoding min-height in CSS

.my-scroll-view {
    min-height: 300px;
}
Enter fullscreen mode Exit fullscreen mode
  • Why it failed: The 1px collapse stopped, but ScrollView lost responsiveness: it stopped adapting dynamically to screen resolution changes and clipped overflowing content.

❌ Failed Approach #2: Invoking actor.queue_relayout() in Constructor

_init() {
    super._init();
    // Attempting to force relayout during construction
    this.queue_relayout(); 
}
Enter fullscreen mode Exit fullscreen mode
  • Why it failed: Calling queue_relayout() inside _init() while GObject properties were partially initialized triggered a recursive layout cycle during startup. This resulted in an empty AppFavorites grid race condition on boot.

❌ Failed Approach #3: Competing Geometry Owners

Setting actor geometry simultaneously from JavaScript (actor.set_width(...)) and CSS (stylesheet.css) created conflicting layout loops where JS and CSS layout managers continuously overwrote allocation boxes.


4. The Solution

Workaround vs Production Adaptive Fix

To test size constraint sensitivity during diagnosis, setting hardcoded bounds acts as an illustrative workaround:

grid.set_size(200, 300); // Diagnostic workaround to confirm constraint sensitivity
Enter fullscreen mode Exit fullscreen mode

For the production adaptive fix:

  1. Single Geometry Owner: Manage dimensions and expansion in one location (JS via Clutter actor flags).
  2. Align Container Constraints: Remove conflicting x_align and y_align parameters from intermediate wrappers.
  3. Use Expansion Flags: Use x_expand: true and y_expand: true so the parent layout manager handles dynamic sizing cleanly.
- const grid = new St.Widget({ 
-     x_align: Clutter.ActorAlign.CENTER, 
-     y_align: Clutter.ActorAlign.FILL 
- });
- grid.queue_relayout();

+ const grid = new St.Widget({
+     style_class: 'app-grid',
+     x_expand: true,
+     y_expand: true,
+ });
Enter fullscreen mode Exit fullscreen mode

5. Verification

1. Repository Validation

Run bash verify.sh to check JS syntax across reproduction/, broken/, and fixed/, verify metadata structure, and execute tools/validate_cases.py:

bash cases/gnome-shell/st-boxlayout-invalid-natural-height/verify.sh
Enter fullscreen mode Exit fullscreen mode

2. Runtime Verification in GNOME Shell

  1. Install and enable the extension in a GNOME Shell 42.9 session (Ubuntu 22.04.5 LTS).
  2. Open the side panel containing St.ScrollView.
  3. Confirm appsScroll allocation height is $> 100\text{px}$ and icons render cleanly without collapsing.

Conclusion

The failure is real: a child actor reported an enormous preferred height near the 32-bit unsigned/float boundary, leaving St.ScrollView clamped to its minimum 1px allocation limit.

The practical fix was to align geometry rules and pass x_expand/y_expand cleanly to a single geometry owner. The exact low-level C mechanism generating $2^{32}$ remains an open topic for further gdb tracing.

Top comments (0)