layer-surface: clear any stale buffer when creating the layer surface - #130
layer-surface: clear any stale buffer when creating the layer surface#130perlowja wants to merge 1 commit into
Conversation
eff35de to
4920e1b
Compare
Every layer-shell window in the shell can be killed by the compositor after a
variable number of open/close cycles:
Gdk-Message: Error 71 (Protocol error) dispatching to Wayland display.
wl_display error 2: zwlr_layer_surface_v1 has never been configured
Root cause, from WAYLAND_DEBUG=1 traces. GTK keeps ONE wl_surface alive across
hide/show. gtk4-layer-shell destroys the zwlr_layer_surface_v1 at unmap and
creates a NEW one over that same wl_surface on the next open. Comparing a
working cycle with a crashing one in a single session:
working (#77 -> #64) crashing (#64 -> #82)
destroy() destroy()
attach(nil); commit() attach(nil); commit()
<nothing> attach(wl_buffer#79); commit() <-- stray
get_layer_surface(#64) get_layer_surface(#82)
commit() -> configure OK commit() -> ERROR 2
The only difference is a frame queued by the closing animation that lands
~0.7ms AFTER the unmap and re-attaches a live buffer to the now-roleless
surface. wl_surface state is persistent, so that buffer is still current when
the next layer surface is created; its first commit therefore carries a buffer
before any configure, which wlroots rejects. Whether that frame lands is a race
on wl_buffer.release, which is why the shell died after a VARIABLE number of
opens rather than deterministically.
unrealize() after hide() drops the GdkSurface, so the next open allocates a
brand-new wl_surface that cannot carry a stale buffer. This is the same call
gtk4-layer-shell itself makes in gtk_layer_surface_remap(), which does
gtk_widget_unrealize() then gtk_widget_map() -- a supported, exercised path.
unrealize() on a never-realized widget is a documented no-op, so the
constructor-time hide() in workspace_overview is harmless.
The cast is required: GtkWindow implements GtkNative, so a bare unrealize()
binds to gtk_native_unrealize, an internal vfunc, not gtk_widget_unrealize.
Verified by inspecting the C that valac emits for each spelling.
This is not launcher-specific. An audit found 19 windows calling
init_for_window(), of which six hide and then re-show -- each one a distinct
instance of the same fault. Applied to all 24 close paths in those six files:
dock.vala 7
sidebar.vala 5
app_menu.vala 4
workspace_overview.vala 4
hot_corner_manager.vala 2
overview.vala 2
Note that a close path is spelled either `hide();` or
`((Gtk.Widget) this).hide();`, and both need the same treatment -- dock.vala
uses the cast form for five of its seven, including the production autohide
reveal, which does that then present(): a full remap.
Verified on CIX Sky1 (Radxa Orion O6N, labwc/wlroots, GTK4, GLES on libmali)
with a Wayland trace analyzer that flags a layer surface being re-created over
a wl_surface that still holds a buffer: 1 dangerous re-creation before the
change, 0 across 36 layer surfaces after it. Confirmed by hand over ~30 open
cycles and across a clean reboot, with zero shell restarts.
Upstream gtk4-layer-shell has no fix. Issue #94 is this exact bug (same error,
same "random after N opens", reproduces only on the gl/vulkan renderers) and
was closed because Hyprland changed; PR #119 reordered teardown and was
rejected as a smithay bug. A library-side fix is proposed separately in
wmww/gtk4-layer-shell#130, which would cover every close path in every client
without each one having to remember; this change is the client-side fix for
shells running against the library as it stands.
A shared close helper would be cleaner than 24 call sites; happy to respin that
way if preferred.
A wl_surface can outlive the layer surface built on it. GTK keeps one
wl_surface across hide/show and tears down only the role object, so
layer_surface_create_surface_object() frequently runs on a surface that was
previously mapped. wl_surface state is persistent, so a buffer that was current
when the previous layer surface was destroyed is still current at that point.
The protocol is explicit that this is not allowed:
Creating a layer surface from a wl_surface which has a buffer attached or
committed is a client error, and any attempts by a client to attach or
manipulate a buffer prior to the first layer_surface.configure call must
also be treated as errors.
So the surface is cleared BEFORE get_layer_surface. attach+commit rather than
attach alone, because attach only sets pending state and would not clear the
CURRENT buffer. The surface is roleless here (the previous role object was
destroyed at unmap), so this is legal, and it is the same sequence GTK performs
in gdk_wayland_surface_hide_surface(). On a genuinely fresh surface it is a
no-op.
Observed with WAYLAND_DEBUG=1, comparing a working open/close cycle against a
crashing one in the same session:
working (wmww#77 -> wmww#64) crashing (wmww#64 -> wmww#82)
destroy() destroy()
attach(nil); commit() attach(nil); commit()
<nothing> attach(wl_buffer#79); commit() <-- stray
get_layer_surface(wmww#64) get_layer_surface(wmww#82)
commit() -> configure OK commit() -> ERROR 2
The only difference is a frame queued by a closing animation that lands ~0.7ms
after the unmap and re-attaches a live buffer.
This is not GTK failing to clean up. gdk_wayland_surface_hide_surface() in GTK
4.22 already calls gdk_wayland_surface_clear_frame_callback() and then
wl_surface_attach(NULL) + wl_surface_commit() -- the attach(nil)/commit visible
in both traces above. The stray attach arrives after all of that.
4920e1b to
e375a26
Compare
Every layer-shell window in the shell can be killed by the compositor after a
variable number of open/close cycles:
Gdk-Message: Error 71 (Protocol error) dispatching to Wayland display.
wl_display error 2: zwlr_layer_surface_v1 has never been configured
Root cause, from WAYLAND_DEBUG=1 traces. GTK keeps ONE wl_surface alive across
hide/show. gtk4-layer-shell destroys the zwlr_layer_surface_v1 at unmap and
creates a NEW one over that same wl_surface on the next open. Comparing a
working cycle with a crashing one in a single session:
working (#77 -> #64) crashing (#64 -> #82)
destroy() destroy()
attach(nil); commit() attach(nil); commit()
<nothing> attach(wl_buffer#79); commit() <-- stray
get_layer_surface(#64) get_layer_surface(#82)
commit() -> configure OK commit() -> ERROR 2
The only difference is a frame queued by the closing animation that lands
~0.7ms AFTER the unmap and re-attaches a live buffer to the now-roleless
surface. wl_surface state is persistent, so that buffer is still current when
the next layer surface is created; its first commit therefore carries a buffer
before any configure, which wlroots rejects. Whether that frame lands is a race
on wl_buffer.release, which is why the shell died after a VARIABLE number of
opens rather than deterministically.
unrealize() after hide() drops the GdkSurface, so the next open allocates a
brand-new wl_surface that cannot carry a stale buffer. This is the same call
gtk4-layer-shell itself makes in gtk_layer_surface_remap(), which does
gtk_widget_unrealize() then gtk_widget_map() -- a supported, exercised path.
unrealize() on a never-realized widget is a documented no-op, so the
constructor-time hide() in workspace_overview is harmless.
The cast is required: GtkWindow implements GtkNative, so a bare unrealize()
binds to gtk_native_unrealize, an internal vfunc, not gtk_widget_unrealize.
Verified by inspecting the C that valac emits for each spelling.
This is not launcher-specific. An audit found 19 windows calling
init_for_window(), of which six hide and then re-show -- each one a distinct
instance of the same fault. Applied to all 24 close paths in those six files:
dock.vala 7
sidebar.vala 5
app_menu.vala 4
workspace_overview.vala 4
hot_corner_manager.vala 2
overview.vala 2
Note that a close path is spelled either `hide();` or
`((Gtk.Widget) this).hide();`, and both need the same treatment -- dock.vala
uses the cast form for five of its seven, including the production autohide
reveal, which does that then present(): a full remap.
Verified on CIX Sky1 (Radxa Orion O6N, labwc/wlroots, GTK4, GLES on libmali)
with a Wayland trace analyzer that flags a layer surface being re-created over
a wl_surface that still holds a buffer: 1 dangerous re-creation before the
change, 0 across 36 layer surfaces after it. Confirmed by hand over ~30 open
cycles and across a clean reboot, with zero shell restarts.
Upstream gtk4-layer-shell has no fix. Issue #94 is this exact bug (same error,
same "random after N opens", reproduces only on the gl/vulkan renderers) and
was closed because Hyprland changed; PR #119 reordered teardown and was
rejected as a smithay bug. A library-side fix is proposed separately in
wmww/gtk4-layer-shell#130, which would cover every close path in every client
without each one having to remember; this change is the client-side fix for
shells running against the library as it stands.
A shared close helper would be cleaner than 24 call sites; happy to respin that
way if preferred.
Every layer-shell window in the shell can be killed by the compositor after a
variable number of open/close cycles:
Gdk-Message: Error 71 (Protocol error) dispatching to Wayland display.
wl_display error 2: zwlr_layer_surface_v1 has never been configured
Root cause, from WAYLAND_DEBUG=1 traces. GTK keeps ONE wl_surface alive across
hide/show. gtk4-layer-shell destroys the zwlr_layer_surface_v1 at unmap and
creates a NEW one over that same wl_surface on the next open. Comparing a
working cycle with a crashing one in a single session:
working (#77 -> #64) crashing (#64 -> #82)
destroy() destroy()
attach(nil); commit() attach(nil); commit()
<nothing> attach(wl_buffer#79); commit() <-- stray
get_layer_surface(#64) get_layer_surface(#82)
commit() -> configure OK commit() -> ERROR 2
The only difference is a frame queued by the closing animation that lands
~0.7ms AFTER the unmap and re-attaches a live buffer to the now-roleless
surface. wl_surface state is persistent, so that buffer is still current when
the next layer surface is created; its first commit therefore carries a buffer
before any configure, which wlroots rejects. Whether that frame lands is a race
on wl_buffer.release, which is why the shell died after a VARIABLE number of
opens rather than deterministically.
unrealize() after hide() drops the GdkSurface, so the next open allocates a
brand-new wl_surface that cannot carry a stale buffer. This is the same call
gtk4-layer-shell itself makes in gtk_layer_surface_remap(), which does
gtk_widget_unrealize() then gtk_widget_map() -- a supported, exercised path.
unrealize() on a never-realized widget is a documented no-op, so the
constructor-time hide() in workspace_overview is harmless.
The cast is required: GtkWindow implements GtkNative, so a bare unrealize()
binds to gtk_native_unrealize, an internal vfunc, not gtk_widget_unrealize.
Verified by inspecting the C that valac emits for each spelling.
This is not launcher-specific. An audit found 19 windows calling
init_for_window(), of which six hide and then re-show -- each one a distinct
instance of the same fault. Applied to all 24 close paths in those six files:
dock.vala 7
sidebar.vala 5
app_menu.vala 4
workspace_overview.vala 4
hot_corner_manager.vala 2
overview.vala 2
Note that a close path is spelled either `hide();` or
`((Gtk.Widget) this).hide();`, and both need the same treatment -- dock.vala
uses the cast form for five of its seven, including the production autohide
reveal, which does that then present(): a full remap.
Verified on CIX Sky1 (Radxa Orion O6N, labwc/wlroots, GTK4, GLES on libmali)
with a Wayland trace analyzer that flags a layer surface being re-created over
a wl_surface that still holds a buffer: 1 dangerous re-creation before the
change, 0 across 36 layer surfaces after it. Confirmed by hand over ~30 open
cycles and across a clean reboot, with zero shell restarts.
Upstream gtk4-layer-shell has no fix. Issue #94 is this exact bug (same error,
same "random after N opens", reproduces only on the gl/vulkan renderers) and
was closed because Hyprland changed; PR #119 reordered teardown and was
rejected as a smithay bug. A library-side fix is proposed separately in
wmww/gtk4-layer-shell#130, which would cover every close path in every client
without each one having to remember; this change is the client-side fix for
shells running against the library as it stands.
A shared close helper would be cleaner than 24 call sites; happy to respin that
way if preferred.
|
I'm sorry you're having problems with the library. My Claude was unable to reproduce this issue or find a likely cause. Please provide:
WAYLAND_DEBUG=1 log is especially important if you're unable to make a minimal reproducer for some reason, or in case I can't reproduce due to hardware differences or something. I'm hesitant to merge the attach null buffer fix as I think it might only partially mitigate the issue, and it could exacerbate Smithay/smithay#1979. |
|
Reproducer below — 90 lines, no shell involved, fails 5/5 on stock 1.3.0 within four layer surfaces. Two ingredients, both easy to missI could not reproduce this at first, and the reason is worth stating because it probably explains why you couldn't either:
Miss either and the surface is always clean at role creation. ResultsSame binary, same compositor, one variable — which
"Dangerous re-creation" = The failing sequence
Environment
Production case that led here: two layer windows — an application chooser and a virtual-desktop chooser — opened and closed at 1080p. The reproducer uses 320x200 windows, so surface size is not a factor. Stepsgcc -O1 -o lsrepro lsrepro.c $(pkg-config --cflags --libs gtk4 gtk4-layer-shell-0)
WAYLAND_DEBUG=1 ./lsrepro 2> wayland.log
# stock: dies within ~4 layer surfaces with the error above
# patched: prints "completed 120 cycles per window with no protocol error"On whether this is the right fixI'm not arguing the layer. The measurement above says this patch removes the dangerous re-creation on the machine that reproduces it; it doesn't tell you whether clearing the buffer in the library is preferable to requiring callers to hand over a clean surface, and it doesn't speak to Smithay/1979 at all — I have no Smithay setup to test against. If you'd rather the guarantee live with the caller, the same effect is available by unrealizing the widget on close (that is what lsrepro.c#include <gtk/gtk.h>
#include <gtk4-layer-shell.h>
#define N_WINDOWS 2
#define MAX_CYCLES 120
typedef struct { GtkWindow *win; GtkWidget *area; guint cycles; } LWin;
static LWin wins[N_WINDOWS];
static GMainLoop *loop;
static guint finished;
static gboolean hide_idle(gpointer data) {
LWin *lw = data;
/* Unmap now: a paint for this surface has already been queued, so its
* buffer can land after the unmap's attach(nil) and remain current when
* the next layer surface is created over the same wl_surface. */
gtk_widget_set_visible(GTK_WIDGET(lw->win), FALSE);
if (++lw->cycles >= MAX_CYCLES && ++finished >= N_WINDOWS)
g_main_loop_quit(loop);
return G_SOURCE_REMOVE;
}
static gboolean tick_cb(GtkWidget *w, GdkFrameClock *clock, gpointer data) {
LWin *lw = data;
(void) clock;
if (!gtk_widget_get_visible(GTK_WIDGET(lw->win)))
return G_SOURCE_CONTINUE;
gtk_widget_queue_draw(w); /* queue a paint ... */
g_idle_add_full(G_PRIORITY_HIGH, hide_idle, lw, NULL); /* ... then unmap */
return G_SOURCE_CONTINUE;
}
static gboolean reopen(gpointer data) {
LWin *lw = data;
if (!gtk_widget_get_visible(GTK_WIDGET(lw->win)) && lw->cycles < MAX_CYCLES)
gtk_widget_set_visible(GTK_WIDGET(lw->win), TRUE);
return G_SOURCE_CONTINUE;
}
static void build(LWin *lw, int i) {
lw->win = GTK_WINDOW(gtk_window_new());
gtk_layer_init_for_window(lw->win);
gtk_layer_set_layer(lw->win, GTK_LAYER_SHELL_LAYER_OVERLAY);
gtk_layer_set_namespace(lw->win, "lsrepro");
gtk_layer_set_anchor(lw->win, GTK_LAYER_SHELL_EDGE_TOP, TRUE);
gtk_layer_set_anchor(lw->win, GTK_LAYER_SHELL_EDGE_LEFT, TRUE);
gtk_layer_set_margin(lw->win, GTK_LAYER_SHELL_EDGE_TOP, 4 + i * 8);
gtk_layer_set_margin(lw->win, GTK_LAYER_SHELL_EDGE_LEFT, 4 + i * 8);
lw->area = gtk_drawing_area_new();
gtk_widget_set_size_request(lw->area, 320, 200);
gtk_window_set_child(lw->win, lw->area);
gtk_widget_add_tick_callback(lw->area, tick_cb, lw, NULL);
gtk_widget_set_visible(GTK_WIDGET(lw->win), TRUE);
g_timeout_add(30 + i * 7, reopen, lw);
}
int main(void) {
gtk_init();
if (!gtk_layer_is_supported()) {
g_printerr("compositor has no zwlr_layer_shell_v1\n");
return 77;
}
for (int i = 0; i < N_WINDOWS; i++)
build(&wins[i], i);
loop = g_main_loop_new(NULL, FALSE);
g_main_loop_run(loop);
g_print("completed %d cycles per window with no protocol error\n", MAX_CYCLES);
return 0;
}tracecheck.py — counts dangerous re-creations in a WAYLAND_DEBUG log#!/usr/bin/env python3
"""Flag zwlr_layer_surface_v1 created over a wl_surface with a live buffer."""
import re, sys
attach_re = re.compile(r'wl_surface@?#?(\d+)\.attach\(\s*(nil|[a-z_]+@?#?\d+)')
commit_re = re.compile(r'wl_surface@?#?(\d+)\.commit\(')
getls_re = re.compile(r'get_layer_surface\(new id zwlr_layer_surface_v1@?#?(\d+), wl_surface@?#?(\d+)')
error_re = re.compile(r'wl_display@?#?\d+\.error\(.*?"([^"]*)"')
pending, current, dangerous, errors, roles = {}, {}, [], [], 0
for raw in open(sys.argv[1], 'rb'):
line = raw.decode('utf-8', 'replace')
m = attach_re.search(line)
if m:
pending[m.group(1)] = None if m.group(2) == 'nil' else m.group(2); continue
m = commit_re.search(line)
if m:
s = m.group(1)
if s in pending: current[s] = pending[s]
continue
m = getls_re.search(line)
if m:
roles += 1
if current.get(m.group(2)) is not None:
dangerous.append((m.group(1), m.group(2), current[m.group(2)]))
continue
m = error_re.search(line)
if m: errors.append(m.group(1))
print("layer surfaces created: %d" % roles)
print("DANGEROUS re-creations: %d" % len(dangerous))
for role, surf, buf in dangerous[:10]:
print(" zwlr_layer_surface_v1#%s over wl_surface#%s still holding %s" % (role, surf, buf))
print("compositor protocol errors: %d" % len(errors))
for e in errors[:5]:
print(" %s" % e) |
|
Still can't repro, and you didn't include a full |
|
Full logs, all three cases, unfiltered, same machine and same binary except where noted: First, why you can't reproduce it: it's the renderer, not just the hardware.
I had The three runs you asked for, all with
So it also crashes without layer shell, and again with the library removed entirely. By the criterion in your last comment, this isn't yours — and having looked at it, I agree. What the trace shows (log 1, lines 1021–1065): GTK already clears the buffer on unmap — wl_surface_attach (impl->display_server.wl_surface, NULL, 0, 0);
wl_surface_commit (impl->display_server.wl_surface);But a frame queued before the hide attaches a buffer after that clear, and I'll open a GTK issue with the reproducer and these logs and link it back here. Given all that, I'd suggest closing this PR rather than merging it: patching the library papers over a GTK bug that equally affects plain XDG surfaces, and you'd be carrying a workaround for something that isn't yours. Happy to turn it into a documentation note about surface reuse if you think that's worth having. Thanks for pushing on the logs — you were right that the layer was wrong. |
|
Correction to my last comment: I wrote that I hadn't called out What is actually new is the other half: with GTK 4.22's default renderer (Vulkan) the reproducer runs clean, 120 cycles per window. So it isn't that the renderer went unmentioned, it's that the failure is specific to the GL/GLES path and silent on the default — which is the part worth knowing if you want to reproduce it on your own hardware without Mali. Everything else in that comment stands: it also fails with the layer-shell calls removed, and again with the library unlinked entirely. |
|
If you decide to report to GTK, be sure to follow their AI Policy and verify+write it up yourself. Also keep it simple with just the pure-GTK reproducer. They (rightly) don't want to deal with gtk-layer-shell bugs. It's not super clear to me that this is a GTK issue. Could also be a graphics driver issue perhaps? You might want to nail down exactly where the bug is before reporting (obviously don't trust an agent that tells you it's found the root cause, it's 100% lying, just like it thought the null buffer attach was a reasonable solution). Anyway, easiest fix is probably just to work around it in your code one way or another. Good luck! EDIT: by the way I've just added an AI policy to this project's README. Future reports are welcome, but must be primarily human written. |
The bug
A
wl_surfacecan outlive the layer surface built on it. GTK keeps onewl_surfaceacross hide/show and tears down only the role object, solayer_surface_create_surface_object()frequently runs on a surface that was previously mapped.wl_surfacestate is persistent, so a buffer that was current when the previous layer surface was destroyed is still current at that point.The protocol requires the first commit after
get_layer_surfaceto carry no buffer. If anything attached one in between, that initial commit carries it and the compositor kills the client:From
WAYLAND_DEBUG=1, a working cycle vs a crashing one in the same session:The only difference is a frame queued by a closing animation landing ~0.7 ms after the unmap.
This isn't GTK failing to clean up
I checked before assuming.
gdk_wayland_surface_hide_surface()in GTK 4.22 already callsgdk_wayland_surface_clear_frame_callback()and thenwl_surface_attach(NULL)+wl_surface_commit()— that's theattach(nil); commit()visible in both traces. The stray attach arrives after all of it. GTK destroys thewl_surfaceonly on destroy, not on hide, so surface reuse across hide/show is deliberate GTK behaviour rather than an oversight.That leaves the gap on the library side: a new role object gets created over a surface whose buffer state nobody re-cleared.
The change
wl_surface_attach(wl_surface, NULL, 0, 0)+wl_surface_commit()immediately beforeget_layer_surface.Placement matters, and the protocol is explicit about it:
So clearing after
get_layer_surfacewould be too late — the error is already committed by then — and the attach itself would be the second half of that sentence. The commit is required too:attachalone only sets pending state and would not clear the current buffer.The surface is roleless at that point (the previous role object was destroyed at unmap), so attach+commit is legal, and it is the same sequence GTK performs in
gdk_wayland_surface_hide_surface(). On a genuinely fresh surface it is a no-op.Relationship to #94 and #119
#94 looks like this exact failure — same error, same "random after N opens", reproduces only on the gl/vulkan renderers — and was closed because Hyprland changed rather than because a client-side cause was found. #119 addressed teardown ordering and was declined as a smithay bug. This is a different mechanism: not ordering, and not compositor-side, but persistent
wl_surfacebuffer state surviving into a new role object.Validation
Hardware: CIX Sky1 (Radxa Orion O6N), labwc/wlroots, GTK4 4.22.4, gtk4-layer-shell 1.3.0, GLES on Mali (libmali).
Tested on metal by isolating the library as the only variable. The client is a shell build that does not contain any client-side workaround, so the library is doing all the work:
e23ebc08(no client-side fix)55919c08e23ebc08(identical binary)0f43ad6dSame binary, same kernel, same session type, minutes apart. On the patched run the shell process is one second younger than the compositor it was launched by and stayed that way throughout testing, i.e. it never restarted.
For completeness on how the mechanism was originally established: a client-side workaround (dropping the
GdkSurfaceon close, so each open gets a freshwl_surface) also eliminates it, and a Wayland trace analyzer that flags a layer surface re-created over a surface still holding a buffer went from 1 dangerous re-creation to 0 across 36 layer surfaces. That workaround is proposed separately downstream, but it has now twice missed close paths that a client has to remember to cover — which is the argument for fixing it here instead, once, for every client.Why this may not reproduce on your hardware
A hypothesis, not a claim. The bug needs the animation frame to land inside the window between unmap and the next open, bounded by when
wl_buffer.releasereturns. On unified-memory Mali the buffer is the same physical page the compositor samples, dmabuf-imported with no copy, so release can come back almost immediately — hence the ~0.7 ms figure. On a discrete GPU the buffer sits behind an explicit VRAM transfer and that window may never open wide enough to hit.That would also explain the gl/vulkan-only character in #94 — those are the renderers that still have a live GPU buffer attached at close time — and the "random after N opens" behaviour, since the race window is set by memory topology rather than by anything in the client.
We should be able to test that directly before long: we're bringing this shell up on an 8 GB Dragon Q8B with a GPU, and I'll report back either way. Happy to hold the PR until then if you'd rather see the cross-hardware result first.
Review notes
An earlier revision of this PR placed the clear after
get_layer_surface. That was wrong for the reason quoted above, and an adversarial review pass on our side caught it before it reached you. Corrected in4920e1b.Two other candidate objections from that review, checked and dismissed with evidence:
wl_surface.attachhook swallow this null attach? No.surface_dataentries are only added insideif (self)increate_xdg_surface_hook, whereself = callback(wl_surface)returns alock_surface_t*. A layer surface has none, so it never enters that table.0, 0offsets correct forwl_surfacev5+? Yes — non-zero offsets raiseinvalid_offset; zero is required, and is valid on older versions too.