diff --git a/cmd/zfs/zfs_iter.c b/cmd/zfs/zfs_iter.c
index bbb6cc55dbd7..efae4eb5f339 100644
--- a/cmd/zfs/zfs_iter.c
+++ b/cmd/zfs/zfs_iter.c
@@ -148,7 +148,15 @@ zfs_callback(zfs_handle_t *zhp, void *data)
if (((zfs_get_type(zhp) & (ZFS_TYPE_SNAPSHOT |
ZFS_TYPE_BOOKMARK)) == 0) && include_snaps) {
- (void) zfs_iter_snapshots_v2(zhp, cb->cb_flags,
+ int snapshot_flags = cb->cb_flags;
+
+ /*
+ * Default sorting and tie breaks require the
+ * creation TXG.
+ */
+ if (snapshot_flags & ZFS_ITER_BATCHED)
+ snapshot_flags |= ZFS_ITER_BATCHED_CREATETXG;
+ (void) zfs_iter_snapshots_v2(zhp, snapshot_flags,
zfs_callback, data, 0, 0);
}
@@ -265,6 +273,135 @@ zfs_list_only_by_fast(const zprop_list_t *p)
return (B_TRUE);
}
+static boolean_t
+zfs_list_batch_inapplicable_prop(int prop)
+{
+ return (prop >= ZFS_PROP_TYPE && prop < ZFS_NUM_PROPS &&
+ !zfs_prop_valid_for_type(prop,
+ ZFS_TYPE_SNAPSHOT | ZFS_TYPE_BOOKMARK, B_FALSE));
+}
+
+/*
+ * Select the projected snapshot iterator only when it can populate every
+ * displayed and sorted property. Optional flags keep unrequested values out
+ * of the kernel calculation and returned nvlist.
+ */
+int
+zfs_list_batch_flags(const zprop_list_t *p, const zfs_sort_column_t *sc)
+{
+ int flags = ZFS_ITER_BATCHED;
+
+ if (p == NULL)
+ return (0);
+
+ for (; p != NULL; p = p->pl_next) {
+ if (p->pl_all)
+ return (0);
+
+ switch (p->pl_prop) {
+ case ZFS_PROP_NAME:
+ case ZFS_PROP_TYPE:
+ break;
+ case ZFS_PROP_USED:
+ flags |= ZFS_ITER_BATCHED_USED;
+ break;
+ case ZFS_PROP_REFERENCED:
+ flags |= ZFS_ITER_BATCHED_REFERENCED;
+ break;
+ case ZFS_PROP_LOGICALREFERENCED:
+ flags |= ZFS_ITER_BATCHED_LOGICALREFERENCED;
+ break;
+ case ZFS_PROP_DEFER_DESTROY:
+ flags |= ZFS_ITER_BATCHED_DEFER_DESTROY;
+ break;
+ case ZFS_PROP_WRITTEN:
+ flags |= ZFS_ITER_BATCHED_WRITTEN;
+ break;
+ case ZFS_PROP_OBJSETID:
+ flags |= ZFS_ITER_BATCHED_OBJSETID;
+ break;
+ case ZFS_PROP_GUID:
+ flags |= ZFS_ITER_BATCHED_GUID;
+ break;
+ case ZFS_PROP_CREATETXG:
+ flags |= ZFS_ITER_BATCHED_CREATETXG;
+ break;
+ case ZFS_PROP_CREATION:
+ flags |= ZFS_ITER_BATCHED_CREATION;
+ break;
+ case ZFS_PROP_USERREFS:
+ flags |= ZFS_ITER_BATCHED_USERREFS;
+ break;
+ case ZFS_PROP_NUMCLONES:
+ flags |= ZFS_ITER_BATCHED_NUMCLONES;
+ break;
+ case ZFS_PROP_INCONSISTENT:
+ flags |= ZFS_ITER_BATCHED_INCONSISTENT;
+ break;
+ case ZFS_PROP_REDACTED:
+ flags |= ZFS_ITER_BATCHED_REDACTED;
+ break;
+ default:
+ if (!zfs_list_batch_inapplicable_prop(p->pl_prop))
+ return (0);
+ break;
+ }
+ }
+
+ for (; sc != NULL; sc = sc->sc_next) {
+ switch (sc->sc_prop) {
+ case ZFS_PROP_NAME:
+ case ZFS_PROP_TYPE:
+ break;
+ case ZFS_PROP_USED:
+ flags |= ZFS_ITER_BATCHED_USED;
+ break;
+ case ZFS_PROP_REFERENCED:
+ flags |= ZFS_ITER_BATCHED_REFERENCED;
+ break;
+ case ZFS_PROP_LOGICALREFERENCED:
+ flags |= ZFS_ITER_BATCHED_LOGICALREFERENCED;
+ break;
+ case ZFS_PROP_DEFER_DESTROY:
+ flags |= ZFS_ITER_BATCHED_DEFER_DESTROY;
+ break;
+ case ZFS_PROP_WRITTEN:
+ flags |= ZFS_ITER_BATCHED_WRITTEN;
+ break;
+ case ZFS_PROP_OBJSETID:
+ flags |= ZFS_ITER_BATCHED_OBJSETID;
+ break;
+ case ZFS_PROP_GUID:
+ flags |= ZFS_ITER_BATCHED_GUID;
+ break;
+ case ZFS_PROP_CREATETXG:
+ flags |= ZFS_ITER_BATCHED_CREATETXG;
+ break;
+ case ZFS_PROP_CREATION:
+ flags |= ZFS_ITER_BATCHED_CREATION;
+ break;
+ case ZFS_PROP_USERREFS:
+ flags |= ZFS_ITER_BATCHED_USERREFS;
+ break;
+ case ZFS_PROP_NUMCLONES:
+ flags |= ZFS_ITER_BATCHED_NUMCLONES;
+ break;
+ case ZFS_PROP_INCONSISTENT:
+ flags |= ZFS_ITER_BATCHED_INCONSISTENT;
+ break;
+ case ZFS_PROP_REDACTED:
+ flags |= ZFS_ITER_BATCHED_REDACTED;
+ break;
+ default:
+ if (!zfs_list_batch_inapplicable_prop(sc->sc_prop))
+ return (0);
+ break;
+ }
+ }
+
+ return (flags);
+}
+
static int
zfs_compare(const void *larg, const void *rarg)
{
diff --git a/cmd/zfs/zfs_iter.h b/cmd/zfs/zfs_iter.h
index 256d64d0030f..b696f23597e2 100644
--- a/cmd/zfs/zfs_iter.h
+++ b/cmd/zfs/zfs_iter.h
@@ -47,6 +47,7 @@ int zfs_add_sort_column(zfs_sort_column_t **, const char *, boolean_t);
void zfs_free_sort_columns(zfs_sort_column_t *);
boolean_t zfs_sort_only_by_fast(const zfs_sort_column_t *);
boolean_t zfs_list_only_by_fast(const zprop_list_t *);
+int zfs_list_batch_flags(const zprop_list_t *, const zfs_sort_column_t *);
#ifdef __cplusplus
}
diff --git a/cmd/zfs/zfs_main.c b/cmd/zfs/zfs_main.c
index 0cd2fcae65f1..ee0189fd3a78 100644
--- a/cmd/zfs/zfs_main.c
+++ b/cmd/zfs/zfs_main.c
@@ -3894,6 +3894,17 @@ list_callback(zfs_handle_t *zhp, void *data)
return (0);
}
+static boolean_t
+zfs_list_has_prop(const zprop_list_t *pl, zfs_prop_t prop)
+{
+ for (; pl != NULL; pl = pl->pl_next) {
+ if (pl->pl_prop == prop)
+ return (B_TRUE);
+ }
+
+ return (B_FALSE);
+}
+
static int
zfs_do_list(int argc, char **argv)
{
@@ -4051,10 +4062,25 @@ found3:;
cb.cb_first = B_TRUE;
/*
- * If we are only going to list and sort by properties that are "fast"
- * then we can use "simple" mode and avoid populating the properties
- * nvlist.
+ * Use projected batches when they contain every displayed and sorted
+ * property. Keep simple mode available for fallback to a kernel that
+ * predates the batch ioctl.
*/
+ int batch_flags = zfs_list_batch_flags(cb.cb_proplist, sortcol);
+ /*
+ * Although AVAILABLE prints "-" for snapshots, its full-stat value is
+ * used with USED to color the field. Projected handles omit it.
+ */
+ if (batch_flags != 0 && !cb.cb_json && use_color() &&
+ (types & ZFS_TYPE_SNAPSHOT) != 0 &&
+ zfs_list_has_prop(cb.cb_proplist, ZFS_PROP_USED) &&
+ zfs_list_has_prop(cb.cb_proplist, ZFS_PROP_AVAILABLE))
+ batch_flags = 0;
+ if (batch_flags != 0) {
+ if (cb.cb_json)
+ batch_flags |= ZFS_ITER_BATCHED_CREATETXG;
+ flags |= batch_flags;
+ }
if (zfs_list_only_by_fast(cb.cb_proplist) &&
zfs_sort_only_by_fast(sortcol))
flags |= ZFS_ITER_SIMPLE;
diff --git a/include/libzfs.h b/include/libzfs.h
index 77506ecd7d72..5f13a4cbc4f0 100644
--- a/include/libzfs.h
+++ b/include/libzfs.h
@@ -729,6 +729,21 @@ _LIBZFS_H int zprop_collect_property(const char *, zprop_get_cbdata_t *,
#define ZFS_ITER_RECVD_PROPS (1 << 4)
#define ZFS_ITER_LITERAL_PROPS (1 << 5)
#define ZFS_ITER_SIMPLE (1 << 6)
+/* Projected snapshot and bookmark fields requested by zfs list. */
+#define ZFS_ITER_BATCHED (1 << 7)
+#define ZFS_ITER_BATCHED_CREATION (1 << 8)
+#define ZFS_ITER_BATCHED_USERREFS (1 << 9)
+#define ZFS_ITER_BATCHED_GUID (1 << 10)
+#define ZFS_ITER_BATCHED_CREATETXG (1 << 11)
+#define ZFS_ITER_BATCHED_NUMCLONES (1 << 12)
+#define ZFS_ITER_BATCHED_INCONSISTENT (1 << 13)
+#define ZFS_ITER_BATCHED_REDACTED (1 << 14)
+#define ZFS_ITER_BATCHED_USED (1 << 15)
+#define ZFS_ITER_BATCHED_REFERENCED (1 << 16)
+#define ZFS_ITER_BATCHED_LOGICALREFERENCED (1 << 17)
+#define ZFS_ITER_BATCHED_DEFER_DESTROY (1 << 18)
+#define ZFS_ITER_BATCHED_OBJSETID (1 << 19)
+#define ZFS_ITER_BATCHED_WRITTEN (1 << 20)
typedef int (*zfs_iter_f)(zfs_handle_t *, void *);
_LIBZFS_H int zfs_iter_root(libzfs_handle_t *, zfs_iter_f, void *);
diff --git a/include/os/freebsd/spl/sys/mod.h b/include/os/freebsd/spl/sys/mod.h
index c6425e88879f..23e4eb5e2aed 100644
--- a/include/os/freebsd/spl/sys/mod.h
+++ b/include/os/freebsd/spl/sys/mod.h
@@ -98,6 +98,12 @@
#define param_set_max_auto_ashift_args(var) \
CTLTYPE_UINT, NULL, 0, param_set_max_auto_ashift, "IU"
+#define param_set_snapshot_list_batch_size_args(var) \
+ CTLTYPE_UINT, NULL, 0, param_set_snapshot_list_batch_size, "IU"
+
+#define param_set_snapshot_list_batch_time_us_args(var) \
+ CTLTYPE_UINT, NULL, 0, param_set_snapshot_list_batch_time_us, "IU"
+
#define param_set_raidz_impl_args(var) \
CTLTYPE_STRING, NULL, 0, param_set_raidz_impl, "A"
diff --git a/include/sys/dsl_dataset.h b/include/sys/dsl_dataset.h
index bde559034b26..7a705dd882c0 100644
--- a/include/sys/dsl_dataset.h
+++ b/include/sys/dsl_dataset.h
@@ -432,6 +432,26 @@ uint64_t dsl_get_inconsistent(dsl_dataset_t *ds);
uint64_t dsl_get_redacted(dsl_dataset_t *ds);
uint64_t dsl_get_available(dsl_dataset_t *ds);
int dsl_get_written(dsl_dataset_t *ds, uint64_t *written);
+
+typedef struct dsl_dataset_snapshot_stats {
+ uint64_t dss_creation_txg;
+ uint64_t dss_creation_time;
+ uint64_t dss_guid;
+ uint64_t dss_userrefs;
+ uint64_t dss_num_clones;
+ uint64_t dss_used;
+ uint64_t dss_referenced;
+ uint64_t dss_logicalreferenced;
+ uint64_t dss_written;
+ uint8_t dss_inconsistent;
+ uint8_t dss_redacted;
+ uint8_t dss_defer_destroy;
+ uint8_t dss_written_valid;
+} dsl_dataset_snapshot_stats_t;
+
+int dsl_dataset_snapshot_stats(struct dsl_pool *dp, uint64_t dsobj,
+ boolean_t want_userrefs, boolean_t want_redacted, boolean_t want_written,
+ uint64_t min_txg, uint64_t max_txg, dsl_dataset_snapshot_stats_t *stats);
int dsl_get_prev_snap(dsl_dataset_t *ds, char *snap);
void dsl_get_redact_snaps(dsl_dataset_t *ds, nvlist_t *propval);
int dsl_get_mountpoint(dsl_dataset_t *ds, const char *dsname, char *value,
diff --git a/include/sys/fs/zfs.h b/include/sys/fs/zfs.h
index 02d30985d1fe..dab7175d23ba 100644
--- a/include/sys/fs/zfs.h
+++ b/include/sys/fs/zfs.h
@@ -1608,6 +1608,28 @@ typedef enum {
*/
#define SNAP_ITER_MIN_TXG "snap_iter_min_txg"
#define SNAP_ITER_MAX_TXG "snap_iter_max_txg"
+#define SNAP_ITER_BATCH_CURSOR "snap_iter_batch_cursor"
+#define SNAP_ITER_BATCH_MAX_RESULTS "snap_iter_batch_max_results"
+#define SNAP_ITER_BATCH_PROPS "snap_iter_batch_props"
+#define SNAP_ITER_BATCH_NAMES "snap_iter_batch_names"
+#define SNAP_ITER_BATCH_CREATETXGS "snap_iter_batch_createtxgs"
+#define SNAP_ITER_BATCH_GUIDS "snap_iter_batch_guids"
+#define SNAP_ITER_BATCH_CREATIONS "snap_iter_batch_creations"
+#define SNAP_ITER_BATCH_USERREF_COUNTS "snap_iter_batch_userref_counts"
+#define SNAP_ITER_BATCH_NUMCLONES "snap_iter_batch_numclones"
+#define SNAP_ITER_BATCH_INCONSISTENT "snap_iter_batch_inconsistent"
+#define SNAP_ITER_BATCH_REDACTED "snap_iter_batch_redacted"
+#define SNAP_ITER_BATCH_USED "snap_iter_batch_used"
+#define SNAP_ITER_BATCH_REFERENCED "snap_iter_batch_referenced"
+#define SNAP_ITER_BATCH_LOGICALREFERENCED \
+ "snap_iter_batch_logicalreferenced"
+#define SNAP_ITER_BATCH_DEFER_DESTROY "snap_iter_batch_defer_destroy"
+#define SNAP_ITER_BATCH_WRITTENS "snap_iter_batch_writtens"
+#define SNAP_ITER_BATCH_WRITTEN_VALID "snap_iter_batch_written_valid"
+#define SNAP_ITER_BATCH_OBJSETIDS "snap_iter_batch_objsetids"
+#define SNAP_ITER_BATCH_DMU_TYPE "snap_iter_batch_dmu_type"
+#define SNAP_ITER_BATCH_DDS_FLAGS "snap_iter_batch_dds_flags"
+#define SNAP_ITER_BATCH_EOF "snap_iter_batch_eof"
/*
* /dev/zfs ioctl numbers.
@@ -1616,7 +1638,7 @@ typedef enum {
*/
typedef enum zfs_ioc {
/*
- * Core features - 89/128 numbers reserved.
+ * Core features - 92/128 numbers reserved.
*/
#ifdef __FreeBSD__
ZFS_IOC_FIRST = 0,
@@ -1715,6 +1737,7 @@ typedef enum zfs_ioc {
ZFS_IOC_POOL_PREFETCH, /* 0x5a58 */
ZFS_IOC_DDT_PRUNE, /* 0x5a59 */
ZFS_IOC_POOL_CONDENSE, /* 0x5a5a */
+ ZFS_IOC_SNAPSHOT_LIST_BATCH, /* 0x5a5b */
/*
* Per-platform (Optional) - 8/128 numbers reserved.
diff --git a/include/sys/zfs_ioctl_impl.h b/include/sys/zfs_ioctl_impl.h
index 74db8a169771..8025ff557f6b 100644
--- a/include/sys/zfs_ioctl_impl.h
+++ b/include/sys/zfs_ioctl_impl.h
@@ -27,6 +27,14 @@
extern kmutex_t zfsdev_state_lock;
extern uint64_t zfs_max_nvlist_src_size;
+#define ZFS_SNAPSHOT_LIST_BATCH_SIZE_DEFAULT 1024
+#define ZFS_SNAPSHOT_LIST_BATCH_SIZE_MAX 4096
+#define ZFS_SNAPSHOT_LIST_BATCH_TIME_US_DEFAULT 10000
+#define ZFS_SNAPSHOT_LIST_BATCH_TIME_US_MAX 100000
+
+extern uint_t zfs_snapshot_list_batch_size;
+extern uint_t zfs_snapshot_list_batch_time_us;
+
typedef int zfs_ioc_legacy_func_t(zfs_cmd_t *);
typedef int zfs_ioc_func_t(const char *, nvlist_t *, nvlist_t *);
typedef int zfs_secpolicy_func_t(zfs_cmd_t *, nvlist_t *, cred_t *);
@@ -82,6 +90,8 @@ void zfs_ioctl_register(const char *, zfs_ioc_t, zfs_ioc_func_t *,
boolean_t, boolean_t, const zfs_ioc_key_t *, size_t);
uint64_t zfs_max_nvlist_src_size_os(void);
+int param_set_snapshot_list_batch_size(ZFS_MODULE_PARAM_ARGS);
+int param_set_snapshot_list_batch_time_us(ZFS_MODULE_PARAM_ARGS);
void zfs_ioctl_update_mount_cache(const char *dsname);
void zfs_ioctl_init_os(void);
diff --git a/lib/libzfs/libzfs.abi b/lib/libzfs/libzfs.abi
index 2b597435ecea..8b67a141d1d2 100644
--- a/lib/libzfs/libzfs.abi
+++ b/lib/libzfs/libzfs.abi
@@ -6616,6 +6616,7 @@
+
diff --git a/lib/libzfs/libzfs_dataset.c b/lib/libzfs/libzfs_dataset.c
index ea316010629b..137efae7e8b5 100644
--- a/lib/libzfs/libzfs_dataset.c
+++ b/lib/libzfs/libzfs_dataset.c
@@ -403,6 +403,10 @@ put_stats_zhdl(zfs_handle_t *zhp, zfs_cmd_t *zc)
zhp->zfs_props = allprops;
zhp->zfs_user_props = userprops;
+ zhp->zfs_projected_creation = 0;
+ zhp->zfs_projected_userrefs = 0;
+ zhp->zfs_projected_props = 0;
+ zhp->zfs_projected_props_materialized = B_FALSE;
return (0);
}
@@ -604,6 +608,11 @@ zfs_handle_dup(zfs_handle_t *zhp_orig)
zhp_orig->zfs_mntopts);
}
zhp->zfs_props_table = zhp_orig->zfs_props_table;
+ zhp->zfs_projected_creation = zhp_orig->zfs_projected_creation;
+ zhp->zfs_projected_userrefs = zhp_orig->zfs_projected_userrefs;
+ zhp->zfs_projected_props = zhp_orig->zfs_projected_props;
+ zhp->zfs_projected_props_materialized =
+ zhp_orig->zfs_projected_props_materialized;
return (zhp);
}
@@ -1865,6 +1874,13 @@ getprop_uint64(zfs_handle_t *zhp, zfs_prop_t prop, const char **source)
uint64_t value;
*source = NULL;
+ if (prop == ZFS_PROP_CREATION &&
+ (zhp->zfs_projected_props & ZFS_PROJECTED_CREATION) != 0)
+ return (zhp->zfs_projected_creation);
+ if (prop == ZFS_PROP_USERREFS &&
+ (zhp->zfs_projected_props & ZFS_PROJECTED_USERREFS) != 0)
+ return (zhp->zfs_projected_userrefs);
+
if (nvlist_lookup_nvlist(zhp->zfs_props,
zfs_prop_to_name(prop), &nv) == 0) {
value = fnvlist_lookup_uint64(nv, ZPROP_VALUE);
@@ -1934,8 +1950,6 @@ static int
get_numeric_property(zfs_handle_t *zhp, zfs_prop_t prop, zprop_source_t *src,
const char **source, uint64_t *val)
{
- zfs_cmd_t zc = {"\0"};
- nvlist_t *zplprops = NULL;
struct mnttab mnt;
const char *mntopt_on = NULL;
const char *mntopt_off = NULL;
@@ -2083,7 +2097,10 @@ get_numeric_property(zfs_handle_t *zhp, zfs_prop_t prop, zprop_source_t *src,
case ZFS_PROP_DEFAULTPROJECTQUOTA:
case ZFS_PROP_DEFAULTUSEROBJQUOTA:
case ZFS_PROP_DEFAULTGROUPOBJQUOTA:
- case ZFS_PROP_DEFAULTPROJECTOBJQUOTA:
+ case ZFS_PROP_DEFAULTPROJECTOBJQUOTA: {
+ zfs_cmd_t zc = {"\0"};
+ nvlist_t *zplprops = NULL;
+
zcmd_alloc_dst_nvlist(zhp->zfs_hdl, &zc, 0);
(void) strlcpy(zc.zc_name, zhp->zfs_name, sizeof (zc.zc_name));
@@ -2103,6 +2120,7 @@ get_numeric_property(zfs_handle_t *zhp, zfs_prop_t prop, zprop_source_t *src,
nvlist_free(zplprops);
zcmd_free_nvlists(&zc);
break;
+ }
case ZFS_PROP_INCONSISTENT:
*val = zhp->zfs_dmustats.dds_inconsistent;
@@ -4510,10 +4528,60 @@ zfs_rename(zfs_handle_t *zhp, const char *target, renameflags_t flags)
return (ret);
}
+static int
+zfs_projected_prop_add(nvlist_t *props, zfs_prop_t prop, uint64_t value)
+{
+ nvlist_t *propval;
+ int error;
+
+ if ((error = nvlist_alloc(&propval, NV_UNIQUE_NAME, 0)) != 0)
+ return (error);
+ if ((error = nvlist_add_uint64(propval, ZPROP_VALUE, value)) == 0) {
+ error = nvlist_add_nvlist(props, zfs_prop_to_name(prop),
+ propval);
+ }
+ nvlist_free(propval);
+ return (error);
+}
+
+/*
+ * Return the handle's property nvlist. Projected handles retain hot numeric
+ * values directly and materialize their traditional nested entries only for
+ * callers of this interface. This preserves the existing nvlist shape while
+ * keeping normal property access allocation-free.
+ */
nvlist_t *
zfs_get_all_props(zfs_handle_t *zhp)
{
+ int error;
+
+ if (!zhp->zfs_projected_props_materialized &&
+ zhp->zfs_projected_props != 0) {
+ if ((zhp->zfs_projected_props & ZFS_PROJECTED_CREATION) != 0) {
+ error = zfs_projected_prop_add(zhp->zfs_props,
+ ZFS_PROP_CREATION, zhp->zfs_projected_creation);
+ if (error != 0)
+ goto nomem;
+ }
+ if ((zhp->zfs_projected_props & ZFS_PROJECTED_USERREFS) != 0) {
+ error = zfs_projected_prop_add(zhp->zfs_props,
+ ZFS_PROP_USERREFS, zhp->zfs_projected_userrefs);
+ if (error != 0)
+ goto rollback;
+ }
+ zhp->zfs_projected_props_materialized = B_TRUE;
+ }
return (zhp->zfs_props);
+
+rollback:
+ if ((zhp->zfs_projected_props & ZFS_PROJECTED_CREATION) != 0) {
+ fnvlist_remove(zhp->zfs_props,
+ zfs_prop_to_name(ZFS_PROP_CREATION));
+ }
+nomem:
+ errno = error;
+ (void) no_memory(zhp->zfs_hdl);
+ return (NULL);
}
nvlist_t *
@@ -4647,6 +4715,10 @@ zfs_prune_proplist(zfs_handle_t *zhp, uint8_t *props)
* properties.
*/
zhp->zfs_props_table = props;
+ if (props[ZFS_PROP_CREATION] == B_FALSE)
+ zhp->zfs_projected_props &= ~ZFS_PROJECTED_CREATION;
+ if (props[ZFS_PROP_USERREFS] == B_FALSE)
+ zhp->zfs_projected_props &= ~ZFS_PROJECTED_USERREFS;
curr = nvlist_next_nvpair(zhp->zfs_props, NULL);
diff --git a/lib/libzfs/libzfs_impl.h b/lib/libzfs/libzfs_impl.h
index 6d7eca981d26..e4717289c205 100644
--- a/lib/libzfs/libzfs_impl.h
+++ b/lib/libzfs/libzfs_impl.h
@@ -82,8 +82,15 @@ struct zfs_handle {
boolean_t zfs_mntcheck;
char *zfs_mntopts;
uint8_t *zfs_props_table;
+ uint64_t zfs_projected_creation;
+ uint64_t zfs_projected_userrefs;
+ uint8_t zfs_projected_props;
+ boolean_t zfs_projected_props_materialized;
};
+#define ZFS_PROJECTED_CREATION (1U << 0)
+#define ZFS_PROJECTED_USERREFS (1U << 1)
+
/*
* Internal namespace property flags for selective remount via
* mount_setattr(2). Passed to zfs_mount_setattr().
diff --git a/lib/libzfs/libzfs_iter.c b/lib/libzfs/libzfs_iter.c
index 1844ce1624b3..71b9dc91151f 100644
--- a/lib/libzfs/libzfs_iter.c
+++ b/lib/libzfs/libzfs_iter.c
@@ -25,9 +25,11 @@
* Copyright (c) 2013, 2019 by Delphix. All rights reserved.
* Copyright 2014 Nexenta Systems, Inc. All rights reserved.
* Copyright (c) 2019 Datto Inc.
+ * Copyright (c) 2026, Wolfgang Hoschek
*/
#include
+#include
#include
#include
#include
@@ -100,6 +102,531 @@ zfs_do_list_ioctl(zfs_handle_t *zhp, int arg, zfs_cmd_t *zc)
return (rc);
}
+static int
+zfs_batch_add_uint64_prop(nvlist_t *props, zfs_prop_t prop, uint64_t value)
+{
+ nvlist_t *propval;
+ int error;
+
+ if ((error = nvlist_alloc(&propval, NV_UNIQUE_NAME, 0)) != 0)
+ return (error);
+ if ((error = nvlist_add_uint64(propval, ZPROP_VALUE, value)) == 0) {
+ error = nvlist_add_nvlist(props, zfs_prop_to_name(prop),
+ propval);
+ }
+ nvlist_free(propval);
+ return (error);
+}
+
+/*
+ * Keep the userspace limit independent of the kernel tunable so a future
+ * kernel can increase its limit without exceeding this caller's destination
+ * buffer. A native packed result needs at most 264 bytes for its name (the
+ * string-array slot and ZFS_MAX_DATASET_NAME_LEN bytes), plus eight bytes for
+ * each uint64 array and one byte for each uint8 array. Reserving twice
+ * ZFS_MAX_DATASET_NAME_LEN bytes for each name, plus the array storage and
+ * 4 KiB for nvpair headers, alignment, and fixed metadata, is a conservative
+ * bound for every currently supported property combination.
+ */
+#define SNAPSHOT_LIST_BATCH_MAX_RESULTS 1024
+#define SNAPSHOT_LIST_BATCH_NVLIST_SIZE(num_uint64_arrays, num_uint8_arrays) \
+ ((4 * 1024) + SNAPSHOT_LIST_BATCH_MAX_RESULTS * \
+ (2 * ZFS_MAX_DATASET_NAME_LEN + \
+ (num_uint64_arrays) * sizeof (uint64_t) + \
+ (num_uint8_arrays) * sizeof (uint8_t)))
+
+static int
+make_dataset_batch_handle(zfs_handle_t *pzhp, const char *snapname,
+ dmu_objset_type_t dmu_type, uint8_t dds_flags,
+ const uint64_t *createtxg, const uint64_t *guid,
+ const uint64_t *objsetid, const uint64_t *creation,
+ const uint64_t *userrefs, const uint64_t *numclones,
+ const uint64_t *used, const uint64_t *referenced,
+ const uint64_t *logicalreferenced, const uint64_t *written,
+ const uint8_t *inconsistent, const uint8_t *redacted,
+ const uint8_t *defer_destroy,
+ zfs_handle_t **result)
+{
+ zfs_handle_t *zhp = calloc(1, sizeof (zfs_handle_t));
+ int error;
+
+ *result = NULL;
+ if (zhp == NULL)
+ return (ENOMEM);
+
+ zhp->zfs_hdl = pzhp->zfs_hdl;
+ zhp->zpool_hdl = zfs_get_pool_handle(pzhp);
+ zhp->zfs_type = ZFS_TYPE_SNAPSHOT;
+ zhp->zfs_head_type = dmu_type == DMU_OST_ZVOL ?
+ ZFS_TYPE_VOLUME : ZFS_TYPE_FILESYSTEM;
+ zhp->zfs_dmustats.dds_type = dmu_type;
+ zhp->zfs_dmustats.dds_is_snapshot = B_TRUE;
+ if (createtxg != NULL)
+ zhp->zfs_dmustats.dds_creation_txg = *createtxg;
+ if (guid != NULL)
+ zhp->zfs_dmustats.dds_guid = *guid;
+ if (numclones != NULL)
+ zhp->zfs_dmustats.dds_num_clones = *numclones;
+ if (inconsistent != NULL)
+ zhp->zfs_dmustats.dds_inconsistent = *inconsistent;
+ if (redacted != NULL)
+ zhp->zfs_dmustats.dds_redacted = *redacted;
+ zhp->zfs_dmustats.dds_flags = dds_flags;
+ if (creation != NULL) {
+ zhp->zfs_projected_creation = *creation;
+ zhp->zfs_projected_props |= ZFS_PROJECTED_CREATION;
+ }
+ if (userrefs != NULL) {
+ zhp->zfs_projected_userrefs = *userrefs;
+ zhp->zfs_projected_props |= ZFS_PROJECTED_USERREFS;
+ }
+
+ if (strlcpy(zhp->zfs_name, pzhp->zfs_name,
+ sizeof (zhp->zfs_name)) >= sizeof (zhp->zfs_name) ||
+ strlcat(zhp->zfs_name, "@", sizeof (zhp->zfs_name)) >=
+ sizeof (zhp->zfs_name) ||
+ strlcat(zhp->zfs_name, snapname, sizeof (zhp->zfs_name)) >=
+ sizeof (zhp->zfs_name)) {
+ error = ENAMETOOLONG;
+ goto fail;
+ }
+
+ if ((error = nvlist_alloc(&zhp->zfs_props, NV_UNIQUE_NAME, 0)) != 0 ||
+ (error = nvlist_alloc(&zhp->zfs_user_props, NV_UNIQUE_NAME, 0)) !=
+ 0) {
+ goto fail;
+ }
+ if ((used != NULL && (error = zfs_batch_add_uint64_prop(
+ zhp->zfs_props, ZFS_PROP_USED, *used)) != 0) ||
+ (objsetid != NULL && (error = zfs_batch_add_uint64_prop(
+ zhp->zfs_props, ZFS_PROP_OBJSETID, *objsetid)) != 0) ||
+ (referenced != NULL && (error = zfs_batch_add_uint64_prop(
+ zhp->zfs_props, ZFS_PROP_REFERENCED, *referenced)) != 0) ||
+ (logicalreferenced != NULL &&
+ (error = zfs_batch_add_uint64_prop(zhp->zfs_props,
+ ZFS_PROP_LOGICALREFERENCED, *logicalreferenced)) != 0) ||
+ (written != NULL && (error = zfs_batch_add_uint64_prop(
+ zhp->zfs_props, ZFS_PROP_WRITTEN, *written)) != 0) ||
+ (defer_destroy != NULL && (error = zfs_batch_add_uint64_prop(
+ zhp->zfs_props, ZFS_PROP_DEFER_DESTROY, *defer_destroy)) != 0)) {
+ goto fail;
+ }
+
+ *result = zhp;
+ return (0);
+
+fail:
+ zfs_close(zhp);
+ return (error);
+}
+
+static int
+zfs_do_snapshot_list_batch_ioctl(zfs_handle_t *zhp, int flags,
+ uint64_t cursor, uint64_t min_txg, uint64_t max_txg, nvlist_t **result,
+ boolean_t *ioctl_eof)
+{
+ zfs_cmd_t zc = {"\0"};
+ nvlist_t *args = fnvlist_alloc();
+ nvlist_t *props = fnvlist_alloc();
+ int error = 0;
+
+ *result = NULL;
+ *ioctl_eof = B_FALSE;
+
+ fnvlist_add_uint64(args, SNAP_ITER_BATCH_CURSOR, cursor);
+ fnvlist_add_uint64(args, SNAP_ITER_BATCH_MAX_RESULTS,
+ SNAPSHOT_LIST_BATCH_MAX_RESULTS);
+ if (min_txg != 0)
+ fnvlist_add_uint64(args, SNAP_ITER_MIN_TXG, min_txg);
+ if (max_txg != 0)
+ fnvlist_add_uint64(args, SNAP_ITER_MAX_TXG, max_txg);
+ if (flags & ZFS_ITER_BATCHED_CREATETXG) {
+ fnvlist_add_boolean(props,
+ zfs_prop_to_name(ZFS_PROP_CREATETXG));
+ }
+ if (flags & ZFS_ITER_BATCHED_CREATION) {
+ fnvlist_add_boolean(props,
+ zfs_prop_to_name(ZFS_PROP_CREATION));
+ }
+ if (flags & ZFS_ITER_BATCHED_GUID)
+ fnvlist_add_boolean(props, zfs_prop_to_name(ZFS_PROP_GUID));
+ if (flags & ZFS_ITER_BATCHED_USERREFS) {
+ fnvlist_add_boolean(props,
+ zfs_prop_to_name(ZFS_PROP_USERREFS));
+ }
+ if (flags & ZFS_ITER_BATCHED_NUMCLONES) {
+ fnvlist_add_boolean(props,
+ zfs_prop_to_name(ZFS_PROP_NUMCLONES));
+ }
+ if (flags & ZFS_ITER_BATCHED_INCONSISTENT) {
+ fnvlist_add_boolean(props,
+ zfs_prop_to_name(ZFS_PROP_INCONSISTENT));
+ }
+ if (flags & ZFS_ITER_BATCHED_REDACTED) {
+ fnvlist_add_boolean(props,
+ zfs_prop_to_name(ZFS_PROP_REDACTED));
+ }
+ if (flags & ZFS_ITER_BATCHED_USED)
+ fnvlist_add_boolean(props, zfs_prop_to_name(ZFS_PROP_USED));
+ if (flags & ZFS_ITER_BATCHED_REFERENCED) {
+ fnvlist_add_boolean(props,
+ zfs_prop_to_name(ZFS_PROP_REFERENCED));
+ }
+ if (flags & ZFS_ITER_BATCHED_LOGICALREFERENCED) {
+ fnvlist_add_boolean(props,
+ zfs_prop_to_name(ZFS_PROP_LOGICALREFERENCED));
+ }
+ if (flags & ZFS_ITER_BATCHED_DEFER_DESTROY) {
+ fnvlist_add_boolean(props,
+ zfs_prop_to_name(ZFS_PROP_DEFER_DESTROY));
+ }
+ if (flags & ZFS_ITER_BATCHED_WRITTEN)
+ fnvlist_add_boolean(props, zfs_prop_to_name(ZFS_PROP_WRITTEN));
+ if (flags & ZFS_ITER_BATCHED_OBJSETID)
+ fnvlist_add_boolean(props, zfs_prop_to_name(ZFS_PROP_OBJSETID));
+ fnvlist_add_nvlist(args, SNAP_ITER_BATCH_PROPS, props);
+
+ (void) strlcpy(zc.zc_name, zhp->zfs_name, sizeof (zc.zc_name));
+ zcmd_write_src_nvlist(zhp->zfs_hdl, &zc, args);
+ zcmd_alloc_dst_nvlist(zhp->zfs_hdl, &zc,
+ SNAPSHOT_LIST_BATCH_NVLIST_SIZE(10, 4));
+ for (;;) {
+ int ioctl_errno = 0;
+
+ if (zfs_ioctl(zhp->zfs_hdl, ZFS_IOC_SNAPSHOT_LIST_BATCH,
+ &zc) != 0) {
+ ioctl_errno = errno;
+ }
+ /*
+ * An ioctl error does not imply that the output nvlist is
+ * empty. It can contain snapshots collected before the error.
+ */
+ if (zc.zc_nvlist_dst_filled) {
+ if (zcmd_read_dst_nvlist(zhp->zfs_hdl, &zc,
+ result) != 0) {
+ nvlist_free(*result);
+ *result = NULL;
+ error = ENOMEM;
+ } else {
+ error = ioctl_errno;
+ /*
+ * Handler errors may return an empty
+ * output nvlist.
+ */
+ if (error != 0 && nvlist_empty(*result)) {
+ nvlist_free(*result);
+ *result = NULL;
+ }
+ }
+ break;
+ }
+ if (ioctl_errno == ENOMEM) {
+ zcmd_expand_dst_nvlist(zhp->zfs_hdl, &zc);
+ continue;
+ }
+ error = ioctl_errno != 0 ? ioctl_errno : EPROTO;
+ break;
+ }
+ if (*result == NULL)
+ *ioctl_eof = error == ENOENT || error == ESRCH;
+
+ zcmd_free_nvlists(&zc);
+ fnvlist_free(props);
+ fnvlist_free(args);
+ return (error);
+}
+
+static int
+zfs_iter_snapshots_batch(zfs_handle_t *zhp, int flags, zfs_iter_f func,
+ void *data, uint64_t min_txg, uint64_t max_txg, boolean_t *unavailable)
+{
+ uint64_t cursor = 0;
+ int ret;
+ boolean_t callback_invoked = B_FALSE;
+
+ *unavailable = B_FALSE;
+
+ for (;;) {
+ char **names = NULL;
+ uint64_t *createtxgs = NULL, *guids = NULL, *objsetids = NULL;
+ uint64_t *creations = NULL, *userrefs = NULL;
+ uint64_t *numclones = NULL, *used = NULL;
+ uint64_t *referenced = NULL, *logicalreferenced = NULL;
+ uint64_t *writtens = NULL;
+ uint8_t *inconsistent = NULL, *redacted = NULL;
+ uint8_t *defer_destroy = NULL, *written_valid = NULL;
+ uint_t count = 0, createtxg_count = 0, guid_count = 0;
+ uint_t objsetid_count = 0;
+ uint_t creation_count = 0, userref_count = 0;
+ uint_t numclone_count = 0, used_count = 0;
+ uint_t referenced_count = 0, logicalreferenced_count = 0;
+ uint_t inconsistent_count = 0, redacted_count = 0;
+ uint_t defer_destroy_count = 0, written_count = 0;
+ uint_t written_valid_count = 0;
+ uint64_t next_cursor, dmu_type, dds_flags;
+ nvlist_t *batch = NULL;
+ boolean_t eof, ioctl_eof;
+ int ioctl_errno;
+
+ ret = zfs_do_snapshot_list_batch_ioctl(zhp, flags, cursor,
+ min_txg, max_txg, &batch, &ioctl_eof);
+ if (ioctl_eof)
+ return (0);
+ if (batch == NULL) {
+ if (ret == 0)
+ ret = EPROTO;
+ if (ret == ZFS_ERR_IOC_CMD_UNAVAIL ||
+ ret == ZFS_ERR_IOC_ARG_UNAVAIL || ret == ENOTTY ||
+ ret == ENOTSUP) {
+ if (callback_invoked == B_FALSE) {
+ *unavailable = B_TRUE;
+ return (ret);
+ }
+ }
+ errno = ret;
+ return (zfs_standard_error(zhp->zfs_hdl, errno,
+ dgettext(TEXT_DOMAIN,
+ "cannot iterate filesystems")));
+ }
+ ioctl_errno = ret;
+
+ eof = nvlist_exists(batch, SNAP_ITER_BATCH_EOF);
+ if (nvlist_lookup_uint64(batch, SNAP_ITER_BATCH_CURSOR,
+ &next_cursor) != 0) {
+ ret = EPROTO;
+ goto malformed;
+ }
+ if (nvlist_lookup_uint64(batch, SNAP_ITER_BATCH_DMU_TYPE,
+ &dmu_type) != 0 ||
+ nvlist_lookup_uint64(batch, SNAP_ITER_BATCH_DDS_FLAGS,
+ &dds_flags) != 0 ||
+ (dmu_type != DMU_OST_ZFS && dmu_type != DMU_OST_ZVOL) ||
+ dds_flags > UINT8_MAX ||
+ (dds_flags & DDS_FLAG_HAS_ENCRYPTED) == 0) {
+ ret = EPROTO;
+ goto malformed;
+ }
+
+ ret = nvlist_lookup_string_array(batch, SNAP_ITER_BATCH_NAMES,
+ &names, &count);
+ if (ret == ENOENT) {
+ count = 0;
+ ret = 0;
+ } else if (ret != 0) {
+ ret = EPROTO;
+ goto malformed;
+ }
+ if (count > SNAPSHOT_LIST_BATCH_MAX_RESULTS) {
+ ret = EPROTO;
+ goto malformed;
+ }
+ if (count != 0 && (flags & ZFS_ITER_BATCHED_CREATETXG) &&
+ (nvlist_lookup_uint64_array(batch,
+ SNAP_ITER_BATCH_CREATETXGS, &createtxgs,
+ &createtxg_count) != 0 || count != createtxg_count)) {
+ ret = EPROTO;
+ goto malformed;
+ }
+ if (count != 0 && (flags & ZFS_ITER_BATCHED_GUID) &&
+ (nvlist_lookup_uint64_array(batch, SNAP_ITER_BATCH_GUIDS,
+ &guids, &guid_count) != 0 || count != guid_count)) {
+ ret = EPROTO;
+ goto malformed;
+ }
+ if (count != 0 && (flags & ZFS_ITER_BATCHED_OBJSETID) &&
+ (nvlist_lookup_uint64_array(batch,
+ SNAP_ITER_BATCH_OBJSETIDS, &objsetids,
+ &objsetid_count) != 0 || count != objsetid_count)) {
+ ret = EPROTO;
+ goto malformed;
+ }
+
+ if (count != 0 && (flags & ZFS_ITER_BATCHED_CREATION) &&
+ (nvlist_lookup_uint64_array(batch,
+ SNAP_ITER_BATCH_CREATIONS, &creations, &creation_count) !=
+ 0 || count != creation_count)) {
+ ret = EPROTO;
+ goto malformed;
+ }
+ if (count != 0 && (flags & ZFS_ITER_BATCHED_USERREFS) &&
+ (nvlist_lookup_uint64_array(batch,
+ SNAP_ITER_BATCH_USERREF_COUNTS, &userrefs,
+ &userref_count) != 0 || count != userref_count)) {
+ ret = EPROTO;
+ goto malformed;
+ }
+ if (count != 0 && (flags & ZFS_ITER_BATCHED_NUMCLONES) &&
+ (nvlist_lookup_uint64_array(batch,
+ SNAP_ITER_BATCH_NUMCLONES, &numclones,
+ &numclone_count) != 0 || count != numclone_count)) {
+ ret = EPROTO;
+ goto malformed;
+ }
+ if (count != 0 && (flags & ZFS_ITER_BATCHED_USED) &&
+ (nvlist_lookup_uint64_array(batch, SNAP_ITER_BATCH_USED,
+ &used, &used_count) != 0 || count != used_count)) {
+ ret = EPROTO;
+ goto malformed;
+ }
+ if (count != 0 && (flags & ZFS_ITER_BATCHED_REFERENCED) &&
+ (nvlist_lookup_uint64_array(batch,
+ SNAP_ITER_BATCH_REFERENCED, &referenced,
+ &referenced_count) != 0 || count != referenced_count)) {
+ ret = EPROTO;
+ goto malformed;
+ }
+ if (count != 0 &&
+ (flags & ZFS_ITER_BATCHED_LOGICALREFERENCED) &&
+ (nvlist_lookup_uint64_array(batch,
+ SNAP_ITER_BATCH_LOGICALREFERENCED, &logicalreferenced,
+ &logicalreferenced_count) != 0 ||
+ count != logicalreferenced_count)) {
+ ret = EPROTO;
+ goto malformed;
+ }
+ if (count != 0 && (flags & ZFS_ITER_BATCHED_INCONSISTENT) &&
+ (nvlist_lookup_uint8_array(batch,
+ SNAP_ITER_BATCH_INCONSISTENT, &inconsistent,
+ &inconsistent_count) != 0 ||
+ count != inconsistent_count)) {
+ ret = EPROTO;
+ goto malformed;
+ }
+ if (count != 0 && (flags & ZFS_ITER_BATCHED_REDACTED) &&
+ (nvlist_lookup_uint8_array(batch,
+ SNAP_ITER_BATCH_REDACTED, &redacted,
+ &redacted_count) != 0 || count != redacted_count)) {
+ ret = EPROTO;
+ goto malformed;
+ }
+ if (count != 0 && (flags & ZFS_ITER_BATCHED_DEFER_DESTROY) &&
+ (nvlist_lookup_uint8_array(batch,
+ SNAP_ITER_BATCH_DEFER_DESTROY, &defer_destroy,
+ &defer_destroy_count) != 0 ||
+ count != defer_destroy_count)) {
+ ret = EPROTO;
+ goto malformed;
+ }
+ if (count != 0 && (flags & ZFS_ITER_BATCHED_WRITTEN) &&
+ (nvlist_lookup_uint64_array(batch,
+ SNAP_ITER_BATCH_WRITTENS, &writtens, &written_count) != 0 ||
+ nvlist_lookup_uint8_array(batch,
+ SNAP_ITER_BATCH_WRITTEN_VALID, &written_valid,
+ &written_valid_count) != 0 || count != written_count ||
+ count != written_valid_count)) {
+ ret = EPROTO;
+ goto malformed;
+ }
+ for (uint_t i = 0; i < count; i++) {
+ if (((flags & ZFS_ITER_BATCHED_INCONSISTENT) &&
+ inconsistent[i] > 1) ||
+ ((flags & ZFS_ITER_BATCHED_REDACTED) &&
+ redacted[i] > 1) ||
+ ((flags & ZFS_ITER_BATCHED_DEFER_DESTROY) &&
+ defer_destroy[i] > 1) ||
+ ((flags & ZFS_ITER_BATCHED_WRITTEN) &&
+ written_valid[i] > 1)) {
+ ret = EPROTO;
+ goto malformed;
+ }
+ }
+
+ for (uint_t i = 0; i < count; i++) {
+ const uint64_t *createtxg =
+ (flags & ZFS_ITER_BATCHED_CREATETXG) ?
+ &createtxgs[i] : NULL;
+ const uint64_t *guid =
+ (flags & ZFS_ITER_BATCHED_GUID) ? &guids[i] : NULL;
+ const uint64_t *objsetid =
+ (flags & ZFS_ITER_BATCHED_OBJSETID) ?
+ &objsetids[i] : NULL;
+ const uint64_t *creation =
+ (flags & ZFS_ITER_BATCHED_CREATION) ?
+ &creations[i] : NULL;
+ const uint64_t *userref =
+ (flags & ZFS_ITER_BATCHED_USERREFS) ?
+ &userrefs[i] : NULL;
+ const uint64_t *numclone =
+ (flags & ZFS_ITER_BATCHED_NUMCLONES) ?
+ &numclones[i] : NULL;
+ const uint64_t *used_value =
+ (flags & ZFS_ITER_BATCHED_USED) ? &used[i] : NULL;
+ const uint64_t *referenced_value =
+ (flags & ZFS_ITER_BATCHED_REFERENCED) ?
+ &referenced[i] : NULL;
+ const uint64_t *logicalreferenced_value =
+ (flags & ZFS_ITER_BATCHED_LOGICALREFERENCED) ?
+ &logicalreferenced[i] : NULL;
+ const uint64_t *written_value =
+ ((flags & ZFS_ITER_BATCHED_WRITTEN) &&
+ written_valid[i]) ? &writtens[i] : NULL;
+ const uint8_t *inconsistent_value =
+ (flags & ZFS_ITER_BATCHED_INCONSISTENT) ?
+ &inconsistent[i] : NULL;
+ const uint8_t *redacted_value =
+ (flags & ZFS_ITER_BATCHED_REDACTED) ?
+ &redacted[i] : NULL;
+ const uint8_t *defer_destroy_value =
+ (flags & ZFS_ITER_BATCHED_DEFER_DESTROY) ?
+ &defer_destroy[i] : NULL;
+ zfs_handle_t *nzhp;
+
+ ret = make_dataset_batch_handle(zhp, names[i],
+ (dmu_objset_type_t)dmu_type, (uint8_t)dds_flags,
+ createtxg, guid, objsetid, creation, userref,
+ numclone,
+ used_value, referenced_value,
+ logicalreferenced_value, written_value,
+ inconsistent_value, redacted_value,
+ defer_destroy_value, &nzhp);
+ if (ret != 0) {
+ int error = ret;
+
+ nvlist_free(batch);
+ errno = error;
+ if (error == ENOMEM)
+ ret = no_memory(zhp->zfs_hdl);
+ else
+ ret = zfs_standard_error(zhp->zfs_hdl,
+ error, dgettext(TEXT_DOMAIN,
+ "cannot iterate filesystems"));
+ errno = error;
+ return (ret);
+ }
+ callback_invoked = B_TRUE;
+ if ((ret = func(nzhp, data)) != 0) {
+ nvlist_free(batch);
+ return (ret);
+ }
+ }
+
+ nvlist_free(batch);
+ if (ioctl_errno != 0) {
+ if (ioctl_errno == ENOENT || ioctl_errno == ESRCH)
+ return (0);
+ errno = ioctl_errno;
+ return (zfs_standard_error(zhp->zfs_hdl, errno,
+ dgettext(TEXT_DOMAIN,
+ "cannot iterate filesystems")));
+ }
+ if (eof)
+ return (0);
+ if (next_cursor == cursor) {
+ ret = EPROTO;
+ batch = NULL;
+ goto malformed;
+ }
+ cursor = next_cursor;
+ continue;
+
+malformed:
+ nvlist_free(batch);
+ errno = ret;
+ return (zfs_standard_error(zhp->zfs_hdl, errno,
+ dgettext(TEXT_DOMAIN, "cannot iterate filesystems")));
+ }
+}
+
/*
* Iterate over all child filesystems
*/
@@ -165,11 +692,18 @@ zfs_iter_snapshots_v2(zfs_handle_t *zhp, int flags, zfs_iter_f func,
zfs_cmd_t zc = {"\0"};
zfs_handle_t *nzhp;
int ret;
+ boolean_t unavailable;
nvlist_t *range_nvl = NULL;
if (zhp->zfs_type == ZFS_TYPE_SNAPSHOT ||
zhp->zfs_type == ZFS_TYPE_BOOKMARK)
return (0);
+ if (flags & ZFS_ITER_BATCHED) {
+ ret = zfs_iter_snapshots_batch(zhp, flags, func, data, min_txg,
+ max_txg, &unavailable);
+ if (!unavailable)
+ return (ret);
+ }
zc.zc_simple = (flags & ZFS_ITER_SIMPLE) != 0;
@@ -219,8 +753,8 @@ zfs_iter_bookmarks(zfs_handle_t *zhp, zfs_iter_f func, void *data)
}
int
-zfs_iter_bookmarks_v2(zfs_handle_t *zhp, int flags __maybe_unused,
- zfs_iter_f func, void *data)
+zfs_iter_bookmarks_v2(zfs_handle_t *zhp, int flags, zfs_iter_f func,
+ void *data)
{
zfs_handle_t *nzhp;
nvlist_t *props = NULL;
@@ -233,12 +767,36 @@ zfs_iter_bookmarks_v2(zfs_handle_t *zhp, int flags __maybe_unused,
/* Setup the requested properties nvlist. */
props = fnvlist_alloc();
- for (zfs_prop_t p = 0; p < ZFS_NUM_PROPS; p++) {
- if (zfs_prop_valid_for_type(p, ZFS_TYPE_BOOKMARK, B_FALSE)) {
- fnvlist_add_boolean(props, zfs_prop_to_name(p));
+ if (flags & ZFS_ITER_BATCHED) {
+ if (flags & ZFS_ITER_BATCHED_GUID) {
+ fnvlist_add_boolean(props,
+ zfs_prop_to_name(ZFS_PROP_GUID));
+ }
+ if (flags & ZFS_ITER_BATCHED_CREATETXG) {
+ fnvlist_add_boolean(props,
+ zfs_prop_to_name(ZFS_PROP_CREATETXG));
+ }
+ if (flags & ZFS_ITER_BATCHED_CREATION) {
+ fnvlist_add_boolean(props,
+ zfs_prop_to_name(ZFS_PROP_CREATION));
+ }
+ if (flags & ZFS_ITER_BATCHED_REFERENCED) {
+ fnvlist_add_boolean(props,
+ zfs_prop_to_name(ZFS_PROP_REFERENCED));
}
+ if (flags & ZFS_ITER_BATCHED_LOGICALREFERENCED) {
+ fnvlist_add_boolean(props,
+ zfs_prop_to_name(ZFS_PROP_LOGICALREFERENCED));
+ }
+ } else {
+ for (zfs_prop_t p = 0; p < ZFS_NUM_PROPS; p++) {
+ if (zfs_prop_valid_for_type(p, ZFS_TYPE_BOOKMARK,
+ B_FALSE)) {
+ fnvlist_add_boolean(props, zfs_prop_to_name(p));
+ }
+ }
+ fnvlist_add_boolean(props, "redact_complete");
}
- fnvlist_add_boolean(props, "redact_complete");
if ((err = lzc_get_bookmarks(zhp->zfs_name, props, &bmarks)) != 0)
goto out;
@@ -346,6 +904,9 @@ zfs_iter_snapshots_sorted_v2(zfs_handle_t *zhp, int flags, zfs_iter_f callback,
avl_create(&avl, zfs_snapshot_compare,
sizeof (zfs_node_t), offsetof(zfs_node_t, zn_avlnode));
+ /* zfs_snapshot_compare() requires the creation TXG. */
+ if (flags & ZFS_ITER_BATCHED)
+ flags |= ZFS_ITER_BATCHED_CREATETXG;
ret = zfs_iter_snapshots_v2(zhp, flags, zfs_sort_snaps, &avl, min_txg,
max_txg);
@@ -590,12 +1151,21 @@ zfs_iter_dependents(zfs_handle_t *zhp, boolean_t allowrecursion,
return (zfs_iter_dependents_v2(zhp, 0, allowrecursion, func, data));
}
+/*
+ * Iterate dependents with full snapshot handles because clone discovery reads
+ * properties which projected handles deliberately omit.
+ */
int
zfs_iter_dependents_v2(zfs_handle_t *zhp, int flags, boolean_t allowrecursion,
zfs_iter_f func, void *data)
{
iter_dependents_arg_t ida;
- ida.flags = flags;
+
+ /*
+ * Both batched and simple snapshot handles omit properties needed for
+ * clone discovery, so dependent traversal must use full handles.
+ */
+ ida.flags = flags & ~(ZFS_ITER_BATCHED | ZFS_ITER_SIMPLE);
ida.allowrecursion = allowrecursion;
ida.stack = NULL;
ida.func = func;
diff --git a/lib/libzfs_core/libzfs_core.abi b/lib/libzfs_core/libzfs_core.abi
index e88de56ae404..3706f7a2d416 100644
--- a/lib/libzfs_core/libzfs_core.abi
+++ b/lib/libzfs_core/libzfs_core.abi
@@ -2704,6 +2704,7 @@
+
diff --git a/man/man4/zfs.4 b/man/man4/zfs.4
index cfa3bc3a5dfe..7f9c8856d5aa 100644
--- a/man/man4/zfs.4
+++ b/man/man4/zfs.4
@@ -1785,6 +1785,33 @@ Whether snapshot creation and destruction events are recorded in the pool
history log, viewable with
.Nm zpool Cm history .
.
+.It Sy zfs_snapshot_list_batch_size Ns = Ns Sy 1024 Pq uint
+Maximum number of snapshots returned by one projected snapshot listing
+batch.
+The effective maximum is the smaller of this value and the limit requested by
+userspace.
+This limit is a proxy for the maximum transient memory consumed by a batch:
+larger values can improve listing throughput at the cost of a larger memory
+footprint.
+Projected snapshot listing is cursor based: a batch stops after either this
+many matching snapshots have been collected or the
+.Sy zfs_snapshot_list_batch_time_us
+budget has been reached, whichever occurs first.
+It returns the matching entries collected so far and a cursor from which a
+subsequent ioctl resumes, leaving the remaining snapshots for later batches.
+The default is 1024 snapshots, and the supported range is 1 to 4096.
+.
+.It Sy zfs_snapshot_list_batch_time_us Ns = Ns Sy 10000 Pq uint
+Soft wall-clock budget for the snapshot scan in one projected snapshot listing
+batch.
+This budget is a proxy for the maximum lock latency a batch can impose on
+unrelated dataset operations.
+The elapsed time is checked after each snapshot is examined by the scan.
+At least one snapshot is therefore examined to ensure cursor progress, and a
+single metadata operation may cause the budget to be exceeded.
+The default is 10000 microseconds, and the supported range is 1 to 100000
+microseconds.
+.
.It Sy zfs_snapshot_no_setuid Ns = Ns Sy 0 Ns | Ns 1 Pq int
Whether to disable
.Em setuid/setgid
diff --git a/module/os/freebsd/zfs/zfs_ioctl_os.c b/module/os/freebsd/zfs/zfs_ioctl_os.c
index 29711fcf5d2c..395b119428a4 100644
--- a/module/os/freebsd/zfs/zfs_ioctl_os.c
+++ b/module/os/freebsd/zfs/zfs_ioctl_os.c
@@ -37,6 +37,40 @@
#include
+/* Set a positive, allocation-bounded snapshot listing batch size. */
+int
+param_set_snapshot_list_batch_size(SYSCTL_HANDLER_ARGS)
+{
+ uint_t value = zfs_snapshot_list_batch_size;
+ int error;
+
+ error = sysctl_handle_int(oidp, &value, 0, req);
+ if (error != 0 || req->newptr == NULL)
+ return (SET_ERROR(error));
+ if (value == 0 || value > ZFS_SNAPSHOT_LIST_BATCH_SIZE_MAX)
+ return (SET_ERROR(EINVAL));
+
+ zfs_snapshot_list_batch_size = value;
+ return (0);
+}
+
+/* Set a positive, bounded snapshot listing wall-clock budget. */
+int
+param_set_snapshot_list_batch_time_us(SYSCTL_HANDLER_ARGS)
+{
+ uint_t value = zfs_snapshot_list_batch_time_us;
+ int error;
+
+ error = sysctl_handle_int(oidp, &value, 0, req);
+ if (error != 0 || req->newptr == NULL)
+ return (SET_ERROR(error));
+ if (value == 0 || value > ZFS_SNAPSHOT_LIST_BATCH_TIME_US_MAX)
+ return (SET_ERROR(EINVAL));
+
+ zfs_snapshot_list_batch_time_us = value;
+ return (0);
+}
+
int
zfs_vfs_ref(zfsvfs_t **zfvp)
{
diff --git a/module/os/linux/zfs/zfs_ioctl_os.c b/module/os/linux/zfs/zfs_ioctl_os.c
index ce6092be1da7..b65adc87bf6b 100644
--- a/module/os/linux/zfs/zfs_ioctl_os.c
+++ b/module/os/linux/zfs/zfs_ioctl_os.c
@@ -69,6 +69,40 @@
#include
#include
+static int
+param_set_snapshot_list_batch_limit(const char *buf, zfs_kernel_param_t *kp,
+ uint_t maximum)
+{
+ uint_t value;
+ int error;
+
+ error = kstrtouint(buf, 0, &value);
+ if (error < 0)
+ return (SET_ERROR(error));
+ if (value == 0 || value > maximum)
+ return (SET_ERROR(-EINVAL));
+
+ error = param_set_uint(buf, kp);
+ return (error < 0 ? SET_ERROR(error) : 0);
+}
+
+/* Set a positive, allocation-bounded snapshot listing batch size. */
+int
+param_set_snapshot_list_batch_size(const char *buf, zfs_kernel_param_t *kp)
+{
+ return (param_set_snapshot_list_batch_limit(buf, kp,
+ ZFS_SNAPSHOT_LIST_BATCH_SIZE_MAX));
+}
+
+/* Set a positive, bounded snapshot listing wall-clock budget. */
+int
+param_set_snapshot_list_batch_time_us(const char *buf,
+ zfs_kernel_param_t *kp)
+{
+ return (param_set_snapshot_list_batch_limit(buf, kp,
+ ZFS_SNAPSHOT_LIST_BATCH_TIME_US_MAX));
+}
+
boolean_t
zfs_vfs_held(zfsvfs_t *zfsvfs)
{
diff --git a/module/zfs/dsl_dataset.c b/module/zfs/dsl_dataset.c
index b7cdebc338d7..e9281cf4a9f9 100644
--- a/module/zfs/dsl_dataset.c
+++ b/module/zfs/dsl_dataset.c
@@ -2701,6 +2701,160 @@ dsl_get_written(dsl_dataset_t *ds, uint64_t *written)
return (err);
}
+/*
+ * Validate the current deadlist, then calculate written bytes only when the
+ * snapshot is in the requested TXG range. Current deadlist errors are
+ * returned, while an excluded snapshot or predecessor error leaves written
+ * invalid. The configuration lock keeps the deadlist object and predecessor
+ * linkage stable.
+ */
+static int
+dsl_dataset_snapshot_written(dsl_pool_t *dp,
+ const dsl_dataset_phys_t *dsp, uint64_t min_txg, uint64_t max_txg,
+ uint64_t *written, uint8_t *written_valid)
+{
+ objset_t *mos = dp->dp_meta_objset;
+ dmu_buf_t *dbuf;
+ dmu_object_info_t doi;
+ uint64_t deadlist_used, prev_referenced;
+ int error;
+
+ ASSERT(dsl_pool_config_held(dp));
+ *written_valid = B_FALSE;
+
+ error = dmu_bonus_hold(mos, dsp->ds_deadlist_obj, FTAG, &dbuf);
+ if (error != 0)
+ return (error);
+
+ dmu_object_info_from_db(dbuf, &doi);
+ if (doi.doi_type == DMU_OT_DEADLIST &&
+ doi.doi_bonus_type == DMU_OT_DEADLIST_HDR &&
+ doi.doi_bonus_size >= sizeof (dsl_deadlist_phys_t)) {
+ const dsl_deadlist_phys_t *dlp = dbuf->db_data;
+
+ deadlist_used = dlp->dl_used;
+ } else if (doi.doi_type == DMU_OT_BPOBJ &&
+ doi.doi_bonus_type == DMU_OT_BPOBJ_HDR &&
+ doi.doi_bonus_size >= BPOBJ_SIZE_V0) {
+ const bpobj_phys_t *bpop = dbuf->db_data;
+
+ deadlist_used = bpop->bpo_bytes;
+ } else {
+ error = SET_ERROR(EINVAL);
+ }
+ dmu_buf_rele(dbuf, FTAG);
+ if (error != 0)
+ return (error);
+
+ if ((min_txg != 0 && dsp->ds_creation_txg < min_txg) ||
+ (max_txg != 0 && dsp->ds_creation_txg > max_txg) ||
+ dsp->ds_prev_snap_obj == 0) {
+ return (0);
+ }
+
+ error = dmu_bonus_hold(mos, dsp->ds_prev_snap_obj, FTAG, &dbuf);
+ if (error != 0)
+ return (0);
+
+ dmu_object_info_from_db(dbuf, &doi);
+ if (doi.doi_bonus_type != DMU_OT_DSL_DATASET ||
+ doi.doi_bonus_size < sizeof (dsl_dataset_phys_t)) {
+ error = SET_ERROR(EINVAL);
+ } else {
+ const dsl_dataset_phys_t *prev_dsp = dbuf->db_data;
+
+ if (prev_dsp->ds_creation_txg != dsp->ds_prev_snap_txg ||
+ prev_dsp->ds_creation_txg >= dsp->ds_creation_txg) {
+ error = SET_ERROR(EINVAL);
+ } else {
+ prev_referenced = prev_dsp->ds_referenced_bytes;
+ }
+ }
+ dmu_buf_rele(dbuf, FTAG);
+ if (error != 0)
+ return (0);
+
+ *written = dsp->ds_referenced_bytes;
+ *written -= prev_referenced;
+ *written += deadlist_used;
+ *written_valid = B_TRUE;
+ return (0);
+}
+
+/*
+ * Retrieve the fields needed by projected snapshot listing without creating
+ * a dsl_dataset_t. The caller holds the pool configuration lock.
+ */
+int
+dsl_dataset_snapshot_stats(dsl_pool_t *dp, uint64_t dsobj,
+ boolean_t want_userrefs, boolean_t want_redacted, boolean_t want_written,
+ uint64_t min_txg, uint64_t max_txg, dsl_dataset_snapshot_stats_t *stats)
+{
+ objset_t *mos = dp->dp_meta_objset;
+ dmu_buf_t *dbuf;
+ dmu_object_info_t doi;
+ dsl_dataset_phys_t *dsp;
+ uint64_t integer_size;
+ boolean_t zapified;
+ int error;
+
+ ASSERT(dsl_pool_config_held(dp));
+ memset(stats, 0, sizeof (*stats));
+
+ error = dmu_bonus_hold(mos, dsobj, FTAG, &dbuf);
+ if (error != 0)
+ return (error);
+
+ dmu_object_info_from_db(dbuf, &doi);
+ zapified = doi.doi_type == DMU_OTN_ZAP_METADATA;
+ if (doi.doi_bonus_type != DMU_OT_DSL_DATASET ||
+ doi.doi_bonus_size < sizeof (dsl_dataset_phys_t)) {
+ error = SET_ERROR(EINVAL);
+ goto out;
+ }
+
+ dsp = dbuf->db_data;
+ stats->dss_creation_txg = dsp->ds_creation_txg;
+ stats->dss_creation_time = dsp->ds_creation_time;
+ stats->dss_guid = dsp->ds_guid;
+ stats->dss_num_clones = dsp->ds_num_children == 0 ?
+ 0 : dsp->ds_num_children - 1;
+ stats->dss_used = dsp->ds_unique_bytes;
+ stats->dss_referenced = dsp->ds_referenced_bytes;
+ stats->dss_logicalreferenced = dsp->ds_uncompressed_bytes;
+ stats->dss_inconsistent =
+ (dsp->ds_flags & DS_FLAG_INCONSISTENT) != 0;
+ stats->dss_defer_destroy =
+ (dsp->ds_flags & DS_FLAG_DEFER_DESTROY) != 0;
+
+ if (want_userrefs && dsp->ds_userrefs_obj != 0) {
+ error = zap_count(mos, dsp->ds_userrefs_obj,
+ &stats->dss_userrefs);
+ if (error != 0)
+ goto out;
+ }
+ if (want_redacted && zapified) {
+ error = zap_length(mos, dsobj,
+ spa_feature_table[SPA_FEATURE_REDACTED_DATASETS].fi_guid,
+ &integer_size, NULL);
+ if (error == 0 && integer_size != sizeof (uint64_t))
+ error = SET_ERROR(EINVAL);
+ if (error == 0) {
+ stats->dss_redacted = B_TRUE;
+ } else if (error == ENOENT) {
+ error = 0;
+ }
+ }
+ if (error == 0 && want_written) {
+ error = dsl_dataset_snapshot_written(dp, dsp, min_txg, max_txg,
+ &stats->dss_written, &stats->dss_written_valid);
+ }
+
+out:
+ dmu_buf_rele(dbuf, FTAG);
+ return (error);
+}
+
/*
* 'snap' should be a buffer of size ZFS_MAX_DATASET_NAME_LEN.
*/
diff --git a/module/zfs/zfs_ioctl.c b/module/zfs/zfs_ioctl.c
index 2c68aa022a4c..54a38892d8b5 100644
--- a/module/zfs/zfs_ioctl.c
+++ b/module/zfs/zfs_ioctl.c
@@ -43,6 +43,7 @@
* Copyright (c) 2019, Allan Jude
* Copyright 2026 Oxide Computer Company
* Copyright (c) 2026, TrueNAS.
+ * Copyright (c) 2026, Wolfgang Hoschek
*/
/*
@@ -2762,6 +2763,404 @@ zfs_ioc_dataset_list_next(zfs_cmd_t *zc)
return (error);
}
+uint_t zfs_snapshot_list_batch_size =
+ ZFS_SNAPSHOT_LIST_BATCH_SIZE_DEFAULT;
+uint_t zfs_snapshot_list_batch_time_us =
+ ZFS_SNAPSHOT_LIST_BATCH_TIME_US_DEFAULT;
+
+/*
+ * innvl: {
+ * (optional uint64) cursor
+ * (optional uint64) minimum creation txg
+ * (optional uint64) maximum creation txg
+ * (uint64) maximum number of returned snapshots
+ * (nvlist) requested properties, each named entry is a boolean
+ * }
+ *
+ * outnvl contains authoritative parent metadata, snapshot names, and one
+ * parallel array for each requested property. Result-count and elapsed-time
+ * limits amortize parent objset and ioctl overhead without holding the pool
+ * configuration lock for an unbounded snapshot walk.
+ */
+static const zfs_ioc_key_t zfs_keys_snapshot_list_batch[] = {
+ {SNAP_ITER_BATCH_PROPS, DATA_TYPE_NVLIST, 0},
+ {SNAP_ITER_BATCH_MAX_RESULTS, DATA_TYPE_UINT64, 0},
+ {SNAP_ITER_BATCH_CURSOR, DATA_TYPE_UINT64, ZK_OPTIONAL},
+ {SNAP_ITER_MIN_TXG, DATA_TYPE_UINT64, ZK_OPTIONAL},
+ {SNAP_ITER_MAX_TXG, DATA_TYPE_UINT64, ZK_OPTIONAL},
+};
+
+static int
+zfs_ioc_snapshot_list_batch(const char *fsname, nvlist_t *innvl,
+ nvlist_t *outnvl)
+{
+ char **names;
+ char *name_storage;
+ uint64_t *createtxgs = NULL, *guids = NULL, *objsetids = NULL;
+ uint64_t *creations = NULL;
+ uint64_t *userrefs = NULL, *numclones = NULL, *used = NULL;
+ uint64_t *referenced = NULL, *logicalreferenced = NULL;
+ uint64_t *writtens = NULL;
+ uint8_t *inconsistent = NULL, *redacted = NULL;
+ uint8_t *defer_destroy = NULL, *written_valid = NULL;
+ nvlist_t *props;
+ uint64_t cursor = 0, min_txg = 0, max_txg = 0, max_results;
+ uint_t batch_size;
+ uint_t batch_time_us = zfs_snapshot_list_batch_time_us;
+ uint_t count = 0;
+ hrtime_t start_time, time_budget;
+ boolean_t eof = B_FALSE;
+ boolean_t want_createtxg = B_FALSE, want_creation = B_FALSE;
+ boolean_t want_guid = B_FALSE, want_objsetid = B_FALSE;
+ boolean_t want_userrefs = B_FALSE;
+ boolean_t want_numclones = B_FALSE, want_inconsistent = B_FALSE;
+ boolean_t want_redacted = B_FALSE, want_used = B_FALSE;
+ boolean_t want_referenced = B_FALSE, want_logicalreferenced = B_FALSE;
+ boolean_t want_defer_destroy = B_FALSE, want_written = B_FALSE;
+ dmu_objset_type_t head_type;
+ uint8_t head_flags;
+ objset_t *os = NULL;
+ int error;
+
+ VERIFY0(nvlist_lookup_uint64(innvl, SNAP_ITER_BATCH_MAX_RESULTS,
+ &max_results));
+ if (max_results == 0)
+ return (SET_ERROR(EINVAL));
+ batch_size = (uint_t)MIN((uint64_t)zfs_snapshot_list_batch_size,
+ max_results);
+
+ VERIFY0(nvlist_lookup_nvlist(innvl, SNAP_ITER_BATCH_PROPS, &props));
+ for (nvpair_t *pair = nvlist_next_nvpair(props, NULL); pair != NULL;
+ pair = nvlist_next_nvpair(props, pair)) {
+ if (nvpair_type(pair) != DATA_TYPE_BOOLEAN)
+ return (SET_ERROR(ZFS_ERR_IOC_ARG_BADTYPE));
+
+ switch (zfs_name_to_prop(nvpair_name(pair))) {
+ case ZFS_PROP_CREATETXG:
+ want_createtxg = B_TRUE;
+ break;
+ case ZFS_PROP_CREATION:
+ want_creation = B_TRUE;
+ break;
+ case ZFS_PROP_GUID:
+ want_guid = B_TRUE;
+ break;
+ case ZFS_PROP_OBJSETID:
+ want_objsetid = B_TRUE;
+ break;
+ case ZFS_PROP_USERREFS:
+ want_userrefs = B_TRUE;
+ break;
+ case ZFS_PROP_NUMCLONES:
+ want_numclones = B_TRUE;
+ break;
+ case ZFS_PROP_INCONSISTENT:
+ want_inconsistent = B_TRUE;
+ break;
+ case ZFS_PROP_REDACTED:
+ want_redacted = B_TRUE;
+ break;
+ case ZFS_PROP_USED:
+ want_used = B_TRUE;
+ break;
+ case ZFS_PROP_REFERENCED:
+ want_referenced = B_TRUE;
+ break;
+ case ZFS_PROP_LOGICALREFERENCED:
+ want_logicalreferenced = B_TRUE;
+ break;
+ case ZFS_PROP_DEFER_DESTROY:
+ want_defer_destroy = B_TRUE;
+ break;
+ case ZFS_PROP_WRITTEN:
+ want_written = B_TRUE;
+ break;
+ default:
+ return (SET_ERROR(ZFS_ERR_IOC_ARG_UNAVAIL));
+ }
+ }
+
+ (void) nvlist_lookup_uint64(innvl, SNAP_ITER_BATCH_CURSOR, &cursor);
+ (void) nvlist_lookup_uint64(innvl, SNAP_ITER_MIN_TXG, &min_txg);
+ (void) nvlist_lookup_uint64(innvl, SNAP_ITER_MAX_TXG, &max_txg);
+
+ names = kmem_alloc(sizeof (names[0]) * batch_size, KM_SLEEP);
+ name_storage = vmem_alloc(ZFS_MAX_DATASET_NAME_LEN * batch_size,
+ KM_SLEEP);
+ for (uint_t i = 0; i < batch_size; i++)
+ names[i] = name_storage + ZFS_MAX_DATASET_NAME_LEN * i;
+ if (want_createtxg) {
+ createtxgs = kmem_alloc(sizeof (createtxgs[0]) * batch_size,
+ KM_SLEEP);
+ }
+ if (want_guid)
+ guids = kmem_alloc(sizeof (guids[0]) * batch_size, KM_SLEEP);
+ if (want_objsetid) {
+ objsetids = kmem_alloc(sizeof (objsetids[0]) * batch_size,
+ KM_SLEEP);
+ }
+ if (want_creation) {
+ creations = kmem_alloc(sizeof (creations[0]) * batch_size,
+ KM_SLEEP);
+ }
+ if (want_userrefs) {
+ userrefs = kmem_alloc(sizeof (userrefs[0]) * batch_size,
+ KM_SLEEP);
+ }
+ if (want_numclones) {
+ numclones = kmem_alloc(sizeof (numclones[0]) * batch_size,
+ KM_SLEEP);
+ }
+ if (want_inconsistent) {
+ inconsistent = kmem_alloc(sizeof (inconsistent[0]) * batch_size,
+ KM_SLEEP);
+ }
+ if (want_redacted) {
+ redacted = kmem_alloc(sizeof (redacted[0]) * batch_size,
+ KM_SLEEP);
+ }
+ if (want_used)
+ used = kmem_alloc(sizeof (used[0]) * batch_size, KM_SLEEP);
+ if (want_referenced) {
+ referenced = kmem_alloc(sizeof (referenced[0]) * batch_size,
+ KM_SLEEP);
+ }
+ if (want_logicalreferenced) {
+ logicalreferenced = kmem_alloc(
+ sizeof (logicalreferenced[0]) * batch_size, KM_SLEEP);
+ }
+ if (want_defer_destroy) {
+ defer_destroy = kmem_alloc(
+ sizeof (defer_destroy[0]) * batch_size, KM_SLEEP);
+ }
+ if (want_written) {
+ writtens = kmem_alloc(sizeof (writtens[0]) * batch_size,
+ KM_SLEEP);
+ written_valid = kmem_alloc(
+ sizeof (written_valid[0]) * batch_size, KM_SLEEP);
+ }
+
+ time_budget = USEC2NSEC((hrtime_t)batch_time_us);
+ error = dmu_objset_hold(fsname, FTAG, &os);
+ if (error != 0)
+ goto out;
+ start_time = gethrtime();
+ head_type = dmu_objset_type(os);
+ head_flags = DDS_FLAG_HAS_ENCRYPTED;
+ if (dmu_objset_ds(os)->ds_dir->dd_crypto_obj != 0)
+ head_flags |= DDS_FLAG_ENCRYPTED;
+
+ while (count < batch_size) {
+ char snapname[ZFS_MAX_DATASET_NAME_LEN];
+ dsl_dataset_snapshot_stats_t stats;
+ uint64_t obj, txg;
+
+ if (issig()) {
+ error = SET_ERROR(EINTR);
+ break;
+ }
+
+ error = dmu_snapshot_list_next(os, sizeof (snapname), snapname,
+ &obj, &cursor, NULL);
+ if (error == ENOENT) {
+ eof = B_TRUE;
+ error = 0;
+ break;
+ } else if (error != 0) {
+ break;
+ }
+ error = dsl_dataset_snapshot_stats(dmu_objset_pool(os), obj,
+ want_userrefs, want_redacted, want_written, min_txg,
+ max_txg, &stats);
+ /*
+ * Preserve public iterator partial-list results after a
+ * post-lookup ENOENT. Reporting EOF lets libzfs consume
+ * entries already in this batch instead of discarding it.
+ */
+ if (error == ENOENT) {
+ eof = B_TRUE;
+ error = 0;
+ break;
+ } else if (error != 0) {
+ break;
+ }
+
+ txg = stats.dss_creation_txg;
+ if ((min_txg == 0 || txg >= min_txg) &&
+ (max_txg == 0 || txg <= max_txg)) {
+ (void) strlcpy(names[count], snapname,
+ ZFS_MAX_DATASET_NAME_LEN);
+ if (want_createtxg)
+ createtxgs[count] = txg;
+ if (want_guid)
+ guids[count] = stats.dss_guid;
+ if (want_objsetid)
+ objsetids[count] = obj;
+ if (want_creation)
+ creations[count] = stats.dss_creation_time;
+ if (want_userrefs)
+ userrefs[count] = stats.dss_userrefs;
+ if (want_numclones)
+ numclones[count] = stats.dss_num_clones;
+ if (want_inconsistent)
+ inconsistent[count] = stats.dss_inconsistent;
+ if (want_redacted)
+ redacted[count] = stats.dss_redacted;
+ if (want_used)
+ used[count] = stats.dss_used;
+ if (want_referenced)
+ referenced[count] = stats.dss_referenced;
+ if (want_logicalreferenced) {
+ logicalreferenced[count] =
+ stats.dss_logicalreferenced;
+ }
+ if (want_defer_destroy)
+ defer_destroy[count] = stats.dss_defer_destroy;
+ if (want_written) {
+ writtens[count] = stats.dss_written;
+ written_valid[count] = stats.dss_written_valid;
+ }
+ count++;
+ }
+
+ if (gethrtime() - start_time >= time_budget)
+ break;
+ }
+
+ dmu_objset_rele(os, FTAG);
+ os = NULL;
+ /*
+ * Return snapshots already collected when processing a later
+ * snapshot fails so libzfs can preserve iterator callback semantics.
+ */
+ if (error == 0 || count != 0) {
+ fnvlist_add_uint64(outnvl, SNAP_ITER_BATCH_CURSOR, cursor);
+ fnvlist_add_uint64(outnvl, SNAP_ITER_BATCH_DMU_TYPE,
+ head_type);
+ fnvlist_add_uint64(outnvl, SNAP_ITER_BATCH_DDS_FLAGS,
+ head_flags);
+ if (eof)
+ fnvlist_add_boolean(outnvl, SNAP_ITER_BATCH_EOF);
+ if (count != 0) {
+ fnvlist_add_string_array(outnvl, SNAP_ITER_BATCH_NAMES,
+ (const char * const *)names, count);
+ if (want_createtxg) {
+ fnvlist_add_uint64_array(outnvl,
+ SNAP_ITER_BATCH_CREATETXGS, createtxgs,
+ count);
+ }
+ if (want_guid) {
+ fnvlist_add_uint64_array(outnvl,
+ SNAP_ITER_BATCH_GUIDS, guids, count);
+ }
+ if (want_objsetid) {
+ fnvlist_add_uint64_array(outnvl,
+ SNAP_ITER_BATCH_OBJSETIDS, objsetids,
+ count);
+ }
+ if (want_creation) {
+ fnvlist_add_uint64_array(outnvl,
+ SNAP_ITER_BATCH_CREATIONS, creations,
+ count);
+ }
+ if (want_userrefs) {
+ fnvlist_add_uint64_array(outnvl,
+ SNAP_ITER_BATCH_USERREF_COUNTS, userrefs,
+ count);
+ }
+ if (want_numclones) {
+ fnvlist_add_uint64_array(outnvl,
+ SNAP_ITER_BATCH_NUMCLONES, numclones,
+ count);
+ }
+ if (want_inconsistent) {
+ fnvlist_add_uint8_array(outnvl,
+ SNAP_ITER_BATCH_INCONSISTENT, inconsistent,
+ count);
+ }
+ if (want_redacted) {
+ fnvlist_add_uint8_array(outnvl,
+ SNAP_ITER_BATCH_REDACTED, redacted, count);
+ }
+ if (want_used) {
+ fnvlist_add_uint64_array(outnvl,
+ SNAP_ITER_BATCH_USED, used, count);
+ }
+ if (want_referenced) {
+ fnvlist_add_uint64_array(outnvl,
+ SNAP_ITER_BATCH_REFERENCED, referenced,
+ count);
+ }
+ if (want_logicalreferenced) {
+ fnvlist_add_uint64_array(outnvl,
+ SNAP_ITER_BATCH_LOGICALREFERENCED,
+ logicalreferenced, count);
+ }
+ if (want_defer_destroy) {
+ fnvlist_add_uint8_array(outnvl,
+ SNAP_ITER_BATCH_DEFER_DESTROY,
+ defer_destroy, count);
+ }
+ if (want_written) {
+ fnvlist_add_uint64_array(outnvl,
+ SNAP_ITER_BATCH_WRITTENS, writtens, count);
+ fnvlist_add_uint8_array(outnvl,
+ SNAP_ITER_BATCH_WRITTEN_VALID,
+ written_valid, count);
+ }
+ }
+ }
+
+out:
+ if (os != NULL)
+ dmu_objset_rele(os, FTAG);
+ vmem_free(name_storage, ZFS_MAX_DATASET_NAME_LEN * batch_size);
+ kmem_free(names, sizeof (names[0]) * batch_size);
+ if (want_createtxg) {
+ kmem_free(createtxgs, sizeof (createtxgs[0]) * batch_size);
+ }
+ if (want_guid)
+ kmem_free(guids, sizeof (guids[0]) * batch_size);
+ if (want_objsetid) {
+ kmem_free(objsetids, sizeof (objsetids[0]) * batch_size);
+ }
+ if (want_creation) {
+ kmem_free(creations, sizeof (creations[0]) * batch_size);
+ }
+ if (want_userrefs) {
+ kmem_free(userrefs, sizeof (userrefs[0]) * batch_size);
+ }
+ if (want_numclones) {
+ kmem_free(numclones, sizeof (numclones[0]) * batch_size);
+ }
+ if (want_inconsistent) {
+ kmem_free(inconsistent,
+ sizeof (inconsistent[0]) * batch_size);
+ }
+ if (want_redacted)
+ kmem_free(redacted, sizeof (redacted[0]) * batch_size);
+ if (want_used)
+ kmem_free(used, sizeof (used[0]) * batch_size);
+ if (want_referenced) {
+ kmem_free(referenced, sizeof (referenced[0]) * batch_size);
+ }
+ if (want_logicalreferenced) {
+ kmem_free(logicalreferenced,
+ sizeof (logicalreferenced[0]) * batch_size);
+ }
+ if (want_defer_destroy) {
+ kmem_free(defer_destroy,
+ sizeof (defer_destroy[0]) * batch_size);
+ }
+ if (want_written) {
+ kmem_free(writtens, sizeof (writtens[0]) * batch_size);
+ kmem_free(written_valid,
+ sizeof (written_valid[0]) * batch_size);
+ }
+
+ return (error);
+}
+
/*
* inputs:
* zc_name name of filesystem
@@ -8088,6 +8487,11 @@ zfs_ioctl_init(void)
zfs_ioc_get_bookmarks, zfs_secpolicy_read, DATASET_NAME,
POOL_CHECK_SUSPENDED, B_FALSE, B_FALSE,
zfs_keys_get_bookmarks, ARRAY_SIZE(zfs_keys_get_bookmarks));
+ zfs_ioctl_register("snapshot_list_batch", ZFS_IOC_SNAPSHOT_LIST_BATCH,
+ zfs_ioc_snapshot_list_batch, zfs_secpolicy_read, DATASET_NAME,
+ POOL_CHECK_SUSPENDED, B_FALSE, B_FALSE,
+ zfs_keys_snapshot_list_batch,
+ ARRAY_SIZE(zfs_keys_snapshot_list_batch));
zfs_ioctl_register("get_bookmark_props", ZFS_IOC_GET_BOOKMARK_PROPS,
zfs_ioc_get_bookmark_props, zfs_secpolicy_read, ENTITY_NAME,
@@ -8896,3 +9300,11 @@ ZFS_MODULE_PARAM(zfs, zfs_, max_nvlist_src_size, U64, ZMOD_RW,
ZFS_MODULE_PARAM(zfs, zfs_, history_output_max, U64, ZMOD_RW,
"Maximum size in bytes of ZFS ioctl output that will be logged");
+
+ZFS_MODULE_PARAM_CALL(zfs, zfs_, snapshot_list_batch_size,
+ param_set_snapshot_list_batch_size, param_get_uint, ZMOD_RW,
+ "Maximum snapshots returned per projected listing batch");
+
+ZFS_MODULE_PARAM_CALL(zfs, zfs_, snapshot_list_batch_time_us,
+ param_set_snapshot_list_batch_time_us, param_get_uint, ZMOD_RW,
+ "Soft projected snapshot listing wall-clock budget in us (1-100000)");
diff --git a/tests/runfiles/common.run b/tests/runfiles/common.run
index 51559f185606..562d03ac6a40 100644
--- a/tests/runfiles/common.run
+++ b/tests/runfiles/common.run
@@ -257,7 +257,9 @@ tests = ['zfs_inherit_001_neg', 'zfs_inherit_002_neg', 'zfs_inherit_003_pos',
tags = ['functional', 'cli_root', 'zfs_inherit']
[tests/functional/cli_root/zfs_list]
-tests = ['zfs_list_009_pos']
+tests = ['zfs_list_009_pos', 'zfs_list_013_pos', 'zfs_list_014_pos',
+ 'zfs_list_010_pos', 'zfs_list_011_pos', 'zfs_list_012_pos',
+ 'zfs_list_016_pos', 'zfs_list_017_pos']
tags = ['functional', 'cli_root', 'zfs_list']
[tests/functional/cli_root/zfs_load-key]
diff --git a/tests/zfs-tests/cmd/.gitignore b/tests/zfs-tests/cmd/.gitignore
index d55bf5daccb9..e1afd5fed540 100644
--- a/tests/zfs-tests/cmd/.gitignore
+++ b/tests/zfs-tests/cmd/.gitignore
@@ -41,6 +41,8 @@
/rename_dir
/rm_lnkcnt_zero_file
/send_doall
+/snapshot_list_test
+/snapshot_list_stats_test
/statx
/stride_dd
/threadsappend
diff --git a/tests/zfs-tests/cmd/Makefile.am b/tests/zfs-tests/cmd/Makefile.am
index b7adf6de7600..0106fcfd547f 100644
--- a/tests/zfs-tests/cmd/Makefile.am
+++ b/tests/zfs-tests/cmd/Makefile.am
@@ -70,6 +70,27 @@ scripts_zfs_tests_bin_PROGRAMS += %D%/libzfs_mnttab_cache_check
%C%_libzfs_mnttab_cache_check_LDADD = \
libzfs.la
+scripts_zfs_tests_bin_PROGRAMS += %D%/snapshot_list_test
+%C%_snapshot_list_test_LDADD = \
+ libzfs.la \
+ libnvpair.la
+
+scripts_zfs_tests_bin_PROGRAMS += %D%/snapshot_list_stats_test
+%C%_snapshot_list_stats_test_CPPFLAGS = $(AM_CPPFLAGS) $(LIBZPOOL_CPPFLAGS)
+# Keep dmu_bonus_hold() wrappable with -Bsymbolic-functions or --disable-static.
+%C%_snapshot_list_stats_test_LDFLAGS = -Wl,--wrap=dmu_bonus_hold
+%C%_snapshot_list_stats_test_LDADD = \
+ module/zfs/libzpool_la-dsl_dataset.lo \
+ libzpool.la \
+ libnvpair.la
+
+scripts_zfs_tests_bin_LTLIBRARIES = %D%/libsnapshot_list_test_shim.la
+%C%_libsnapshot_list_test_shim_la_LDFLAGS = -module -avoid-version
+%C%_libsnapshot_list_test_shim_la_LIBADD = libnvpair.la
+if BUILD_LINUX
+%C%_libsnapshot_list_test_shim_la_LIBADD += -ldl
+endif
+
scripts_zfs_tests_bin_PROGRAMS += %D%/manipulate_user_buffer
scripts_zfs_tests_bin_PROGRAMS += %D%/mkbusy %D%/mkfile %D%/mkfiles %D%/mktree
diff --git a/tests/zfs-tests/cmd/libsnapshot_list_test_shim.c b/tests/zfs-tests/cmd/libsnapshot_list_test_shim.c
new file mode 100644
index 000000000000..815d5772b980
--- /dev/null
+++ b/tests/zfs-tests/cmd/libsnapshot_list_test_shim.c
@@ -0,0 +1,524 @@
+// SPDX-License-Identifier: CDDL-1.0
+/*
+ * Trace or inject errors into projected snapshot listing. This library is
+ * only loaded explicitly by ZTS tests.
+ */
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include
+#include
+#include
+
+typedef int (*lzc_ioctl_fd_fn_t)(int, unsigned long, zfs_cmd_t *);
+typedef int (*lzc_get_bookmarks_fn_t)(const char *, nvlist_t *, nvlist_t **);
+typedef int (*nvlist_add_nvlist_fn_t)(nvlist_t *, const char *,
+ const nvlist_t *);
+
+static int handle_enomem_armed;
+static int handle_enomem_injected;
+static int empty_batch_injected;
+static int real_enomem_injected;
+
+static int
+write_all(int fd, const char *buffer, size_t length)
+{
+ ssize_t written;
+
+ while (length > 0) {
+ written = write(fd, buffer, length);
+ if (written > 0) {
+ buffer += written;
+ length -= written;
+ continue;
+ }
+ if (written == -1 && errno == EINTR)
+ continue;
+ return (-1);
+ }
+ return (0);
+}
+
+static lzc_ioctl_fd_fn_t
+find_lzc_ioctl_fd(void)
+{
+ void *symbol = dlsym(RTLD_NEXT, "lzc_ioctl_fd");
+ lzc_ioctl_fd_fn_t function;
+
+ (void) memcpy(&function, &symbol, sizeof (function));
+ return (function);
+}
+
+static lzc_get_bookmarks_fn_t
+find_lzc_get_bookmarks(void)
+{
+ void *symbol = dlsym(RTLD_NEXT, "lzc_get_bookmarks");
+ lzc_get_bookmarks_fn_t function;
+
+ (void) memcpy(&function, &symbol, sizeof (function));
+ return (function);
+}
+
+static nvlist_add_nvlist_fn_t
+find_nvlist_add_nvlist(void)
+{
+ void *symbol = dlsym(RTLD_NEXT, "nvlist_add_nvlist");
+ nvlist_add_nvlist_fn_t function;
+
+ (void) memcpy(&function, &symbol, sizeof (function));
+ return (function);
+}
+
+static void
+write_marker(const char *mode)
+{
+ const char *path;
+ int fd;
+ int saved_errno = errno;
+
+ path = getenv("ZFS_SNAPSHOT_LIST_TEST_MARKER");
+ if (path != NULL) {
+ fd = open(path, O_WRONLY | O_CREAT | O_APPEND, 0600);
+ if (fd >= 0) {
+ if (write_all(fd, mode, strlen(mode)) == 0)
+ (void) write_all(fd, "\n", 1);
+ (void) close(fd);
+ }
+ }
+ errno = saved_errno;
+}
+
+static int
+pack_batch(zfs_cmd_t *zc, nvlist_t *batch)
+{
+ char *packed = NULL;
+ size_t packed_size = 0;
+ int error;
+
+ error = nvlist_pack(batch, &packed, &packed_size, NV_ENCODE_NATIVE, 0);
+ if (error != 0)
+ goto out;
+ if (packed_size > zc->zc_nvlist_dst_size) {
+ error = EPROTO;
+ goto out;
+ }
+
+ (void) memcpy((void *)(uintptr_t)zc->zc_nvlist_dst, packed,
+ packed_size);
+ zc->zc_nvlist_dst_size = packed_size;
+ zc->zc_nvlist_dst_filled = B_TRUE;
+
+out:
+ free(packed);
+ return (error);
+}
+
+static boolean_t
+batch_has_snapshot_results(const zfs_cmd_t *zc)
+{
+ nvlist_t *batch = NULL;
+ char **names;
+ uint_t count;
+ boolean_t result = B_FALSE;
+ int saved_errno = errno;
+
+ if (zc->zc_nvlist_dst_filled && zc->zc_nvlist_dst != 0 &&
+ zc->zc_nvlist_dst_size != 0 &&
+ nvlist_unpack((char *)(uintptr_t)zc->zc_nvlist_dst,
+ zc->zc_nvlist_dst_size, &batch, 0) == 0 &&
+ nvlist_lookup_string_array(batch, SNAP_ITER_BATCH_NAMES, &names,
+ &count) == 0 && count != 0) {
+ result = B_TRUE;
+ }
+
+ nvlist_free(batch);
+ errno = saved_errno;
+ return (result);
+}
+
+/*
+ * Preserve the kernel's opaque cursor while omitting this batch's results.
+ * This deterministically exercises continuation after an empty, non-EOF reply.
+ */
+static int
+replace_with_empty_batch(zfs_cmd_t *zc)
+{
+ nvlist_t *batch = NULL;
+ nvlist_t *replacement = NULL;
+ uint64_t cursor, dmu_type, dds_flags;
+ int error;
+
+ error = nvlist_unpack((void *)(uintptr_t)zc->zc_nvlist_dst,
+ zc->zc_nvlist_dst_size, &batch, 0);
+ if (error != 0)
+ goto out;
+ if (nvlist_exists(batch, SNAP_ITER_BATCH_EOF) ||
+ nvlist_lookup_uint64(batch, SNAP_ITER_BATCH_CURSOR, &cursor) != 0 ||
+ nvlist_lookup_uint64(batch, SNAP_ITER_BATCH_DMU_TYPE,
+ &dmu_type) != 0 ||
+ nvlist_lookup_uint64(batch, SNAP_ITER_BATCH_DDS_FLAGS,
+ &dds_flags) != 0) {
+ error = EPROTO;
+ goto out;
+ }
+
+ error = nvlist_alloc(&replacement, NV_UNIQUE_NAME, 0);
+ if (error != 0)
+ goto out;
+ error = nvlist_add_uint64(replacement, SNAP_ITER_BATCH_CURSOR, cursor);
+ if (error != 0)
+ goto out;
+ error = nvlist_add_uint64(replacement, SNAP_ITER_BATCH_DMU_TYPE,
+ dmu_type);
+ if (error != 0)
+ goto out;
+ error = nvlist_add_uint64(replacement, SNAP_ITER_BATCH_DDS_FLAGS,
+ dds_flags);
+ if (error != 0)
+ goto out;
+ error = pack_batch(zc, replacement);
+
+out:
+ nvlist_free(replacement);
+ nvlist_free(batch);
+ return (error);
+}
+
+static boolean_t
+is_metadata_mode(const char *mode)
+{
+ return (strcmp(mode, "missing_dmu_type") == 0 ||
+ strcmp(mode, "invalid_dmu_type") == 0 ||
+ strcmp(mode, "missing_dds_flags") == 0 ||
+ strcmp(mode, "invalid_dds_flags") == 0);
+}
+
+static boolean_t
+is_written_metadata_mode(const char *mode)
+{
+ return (strcmp(mode, "missing_writtens") == 0 ||
+ strcmp(mode, "missing_written_valid") == 0 ||
+ strcmp(mode, "short_writtens") == 0 ||
+ strcmp(mode, "short_written_valid") == 0 ||
+ strcmp(mode, "invalid_written_valid") == 0);
+}
+
+static int
+replace_batch_metadata(zfs_cmd_t *zc, const char *mode)
+{
+ nvlist_t *batch = NULL;
+ const char *name;
+ boolean_t invalid;
+ int error;
+
+ if (strcmp(mode, "missing_dmu_type") == 0 ||
+ strcmp(mode, "invalid_dmu_type") == 0) {
+ name = SNAP_ITER_BATCH_DMU_TYPE;
+ invalid = strcmp(mode, "invalid_dmu_type") == 0;
+ } else {
+ name = SNAP_ITER_BATCH_DDS_FLAGS;
+ invalid = strcmp(mode, "invalid_dds_flags") == 0;
+ }
+
+ error = nvlist_unpack((void *)(uintptr_t)zc->zc_nvlist_dst,
+ zc->zc_nvlist_dst_size, &batch, 0);
+ if (error != 0)
+ goto out;
+ (void) nvlist_remove_all(batch, name);
+ if (invalid &&
+ (error = nvlist_add_uint64(batch, name, UINT64_MAX)) != 0) {
+ goto out;
+ }
+ error = pack_batch(zc, batch);
+
+out:
+ nvlist_free(batch);
+ return (error);
+}
+
+static int
+replace_written_metadata(zfs_cmd_t *zc, const char *mode)
+{
+ nvlist_t *batch = NULL;
+ uint64_t *writtens, *writtens_copy = NULL;
+ uint8_t *valid, *valid_copy = NULL;
+ uint_t count;
+ int error;
+
+ error = nvlist_unpack((void *)(uintptr_t)zc->zc_nvlist_dst,
+ zc->zc_nvlist_dst_size, &batch, 0);
+ if (error != 0)
+ goto out;
+
+ if (strcmp(mode, "missing_writtens") == 0) {
+ (void) nvlist_remove_all(batch, SNAP_ITER_BATCH_WRITTENS);
+ } else if (strcmp(mode, "missing_written_valid") == 0) {
+ (void) nvlist_remove_all(batch, SNAP_ITER_BATCH_WRITTEN_VALID);
+ } else if (strcmp(mode, "short_writtens") == 0) {
+ error = nvlist_lookup_uint64_array(batch,
+ SNAP_ITER_BATCH_WRITTENS, &writtens, &count);
+ if (error != 0 || count < 2) {
+ error = EPROTO;
+ goto out;
+ }
+ writtens_copy = malloc(sizeof (writtens_copy[0]) * count);
+ if (writtens_copy == NULL) {
+ error = ENOMEM;
+ goto out;
+ }
+ (void) memcpy(writtens_copy, writtens,
+ sizeof (writtens_copy[0]) * count);
+ (void) nvlist_remove_all(batch, SNAP_ITER_BATCH_WRITTENS);
+ error = nvlist_add_uint64_array(batch, SNAP_ITER_BATCH_WRITTENS,
+ writtens_copy, count - 1);
+ if (error != 0)
+ goto out;
+ } else {
+ error = nvlist_lookup_uint8_array(batch,
+ SNAP_ITER_BATCH_WRITTEN_VALID, &valid, &count);
+ if (error != 0 || count < 2) {
+ error = EPROTO;
+ goto out;
+ }
+ valid_copy = malloc(sizeof (valid_copy[0]) * count);
+ if (valid_copy == NULL) {
+ error = ENOMEM;
+ goto out;
+ }
+ (void) memcpy(valid_copy, valid,
+ sizeof (valid_copy[0]) * count);
+ (void) nvlist_remove_all(batch, SNAP_ITER_BATCH_WRITTEN_VALID);
+ if (strcmp(mode, "invalid_written_valid") == 0)
+ valid_copy[0] = 2;
+ else
+ count--;
+ error = nvlist_add_uint8_array(batch,
+ SNAP_ITER_BATCH_WRITTEN_VALID, valid_copy, count);
+ if (error != 0)
+ goto out;
+ }
+
+ error = pack_batch(zc, batch);
+
+out:
+ free(valid_copy);
+ free(writtens_copy);
+ nvlist_free(batch);
+ return (error);
+}
+
+static boolean_t
+matches_target(const char *name, const char *target)
+{
+ return (target == NULL || strcmp(name, target) == 0);
+}
+
+static boolean_t
+is_default_projected_bookmark_props(nvlist_t *props)
+{
+ nvpair_t *pair = NULL;
+ unsigned int count = 0;
+
+ while ((pair = nvlist_next_nvpair(props, pair)) != NULL)
+ count++;
+ return (count == 1 &&
+ nvlist_exists(props, zfs_prop_to_name(ZFS_PROP_REFERENCED)));
+}
+
+int
+lzc_get_bookmarks(const char *fsname, nvlist_t *props, nvlist_t **bmarks)
+{
+ static lzc_get_bookmarks_fn_t next;
+ const char *mode = getenv("ZFS_SNAPSHOT_LIST_TEST_MODE");
+ const char *target = getenv("ZFS_SNAPSHOT_LIST_TEST_TARGET");
+ int error = 0;
+
+ if (mode != NULL && strcmp(mode, "bookmark_eio") == 0)
+ error = EIO;
+ else if (mode != NULL && strcmp(mode, "bookmark_enoent") == 0)
+ error = ENOENT;
+ else if (mode != NULL && strcmp(mode, "bookmark_esrch") == 0)
+ error = ESRCH;
+ if (error != 0) {
+ write_marker(mode);
+ return (error);
+ }
+ if (mode != NULL && strcmp(mode, "bookmark_projected") == 0 &&
+ matches_target(fsname, target)) {
+ if (!is_default_projected_bookmark_props(props)) {
+ write_marker("bookmark_not_projected");
+ return (EPROTO);
+ }
+ write_marker(mode);
+ }
+ if (mode != NULL && strcmp(mode, "bookmark_empty_projected") == 0 &&
+ matches_target(fsname, target)) {
+ if (nvlist_next_nvpair(props, NULL) != NULL) {
+ write_marker("bookmark_not_empty_projected");
+ return (EPROTO);
+ }
+ write_marker(mode);
+ }
+
+ if (next == NULL)
+ next = find_lzc_get_bookmarks();
+ if (next == NULL)
+ return (ENOSYS);
+ return (next(fsname, props, bmarks));
+}
+
+int
+nvlist_add_nvlist(nvlist_t *nvl, const char *name, const nvlist_t *val)
+{
+ static nvlist_add_nvlist_fn_t next;
+ const char *mode = getenv("ZFS_SNAPSHOT_LIST_TEST_MODE");
+
+ if (handle_enomem_armed && mode != NULL) {
+ if (!handle_enomem_injected &&
+ strcmp(mode, "handle_enomem") == 0 &&
+ strcmp(name, "userrefs") == 0) {
+ handle_enomem_injected = 1;
+ write_marker(mode);
+ return (ENOMEM);
+ }
+ if (strcmp(mode, "direct_properties") == 0 &&
+ (strcmp(name, "creation") == 0 ||
+ strcmp(name, "userrefs") == 0)) {
+ write_marker("unexpected_property_nvlist");
+ return (ENOMEM);
+ }
+ }
+
+ if (next == NULL)
+ next = find_nvlist_add_nvlist();
+ if (next == NULL)
+ return (ENOSYS);
+ return (next(nvl, name, val));
+}
+
+int
+lzc_ioctl_fd(int fd, unsigned long request, zfs_cmd_t *zc)
+{
+ static lzc_ioctl_fd_fn_t next;
+ static int enomem_injected;
+ static unsigned int batch_calls;
+ const char *mode = getenv("ZFS_SNAPSHOT_LIST_TEST_MODE");
+ int error;
+ int injected_errno = 0;
+
+ if (request == ZFS_IOC_SNAPSHOT_LIST_BATCH && mode != NULL) {
+ batch_calls++;
+ if (strcmp(mode, "count") == 0)
+ write_marker(mode);
+ else if (strcmp(mode, "enotty") == 0)
+ injected_errno = ENOTTY;
+ else if (strcmp(mode, "enotsup") == 0)
+ injected_errno = ENOTSUP;
+ else if (strcmp(mode, "enotsup_after_first") == 0 &&
+ batch_calls > 1)
+ injected_errno = ENOTSUP;
+ else if (strcmp(mode, "enoent_after_first") == 0 &&
+ batch_calls > 1)
+ injected_errno = ENOENT;
+ else if (strcmp(mode, "esrch_after_first") == 0 &&
+ batch_calls > 1)
+ injected_errno = ESRCH;
+ else if (strcmp(mode, "cmd_unavail") == 0)
+ injected_errno = ZFS_ERR_IOC_CMD_UNAVAIL;
+ else if (strcmp(mode, "arg_unavail") == 0)
+ injected_errno = ZFS_ERR_IOC_ARG_UNAVAIL;
+ else if (strcmp(mode, "enoent") == 0)
+ injected_errno = ENOENT;
+ else if (strcmp(mode, "esrch") == 0)
+ injected_errno = ESRCH;
+ else if (strcmp(mode, "eintr") == 0)
+ injected_errno = EINTR;
+
+ if (injected_errno != 0) {
+ write_marker(mode);
+ errno = injected_errno;
+ return (-1);
+ }
+ if (strcmp(mode, "enomem") == 0 && !enomem_injected) {
+ enomem_injected = 1;
+ zc->zc_nvlist_dst_size *= 2;
+ write_marker(mode);
+ errno = ENOMEM;
+ return (-1);
+ }
+ }
+
+ if (next == NULL)
+ next = find_lzc_ioctl_fd();
+ if (next == NULL) {
+ errno = ENOSYS;
+ return (-1);
+ }
+ if (request == ZFS_IOC_SNAPSHOT_LIST_BATCH && mode != NULL &&
+ strcmp(mode, "real_enomem") == 0 && !real_enomem_injected) {
+ /*
+ * Keep the allocation, but advertise too little room for
+ * copyout.
+ */
+ real_enomem_injected = 1;
+ zc->zc_nvlist_dst_size = 1;
+ error = next(fd, request, zc);
+ if (error == -1 && errno == ENOMEM &&
+ zc->zc_nvlist_dst_size > 1) {
+ write_marker(mode);
+ return (error);
+ }
+ errno = EPROTO;
+ return (-1);
+ }
+ error = next(fd, request, zc);
+ if (error == -1 && errno == EIO &&
+ request == ZFS_IOC_SNAPSHOT_LIST_BATCH && mode != NULL &&
+ strcmp(mode, "partial_eio_output") == 0 &&
+ batch_has_snapshot_results(zc)) {
+ write_marker(mode);
+ }
+ if (error == 0 && request == ZFS_IOC_SNAPSHOT_LIST_BATCH &&
+ mode != NULL && strcmp(mode, "empty_non_eof") == 0 &&
+ !empty_batch_injected) {
+ empty_batch_injected = 1;
+ error = replace_with_empty_batch(zc);
+ if (error != 0) {
+ errno = error;
+ return (-1);
+ }
+ write_marker(mode);
+ }
+ if (error == 0 && request == ZFS_IOC_SNAPSHOT_LIST_BATCH &&
+ mode != NULL && is_metadata_mode(mode)) {
+ error = replace_batch_metadata(zc, mode);
+ if (error != 0) {
+ errno = error;
+ return (-1);
+ }
+ write_marker(mode);
+ }
+ if (error == 0 && request == ZFS_IOC_SNAPSHOT_LIST_BATCH &&
+ mode != NULL && is_written_metadata_mode(mode)) {
+ error = replace_written_metadata(zc, mode);
+ if (error != 0) {
+ errno = error;
+ return (-1);
+ }
+ write_marker(mode);
+ }
+ if (error == 0 && request == ZFS_IOC_SNAPSHOT_LIST_BATCH &&
+ mode != NULL && (strcmp(mode, "handle_enomem") == 0 ||
+ strcmp(mode, "direct_properties") == 0)) {
+ handle_enomem_armed = 1;
+ if (strcmp(mode, "direct_properties") == 0)
+ write_marker(mode);
+ }
+ return (error);
+}
diff --git a/tests/zfs-tests/cmd/libzfs_input_check.c b/tests/zfs-tests/cmd/libzfs_input_check.c
index 3c9d369b1e54..f951f0948dde 100644
--- a/tests/zfs-tests/cmd/libzfs_input_check.c
+++ b/tests/zfs-tests/cmd/libzfs_input_check.c
@@ -561,6 +561,69 @@ test_get_bookmarks(const char *dataset)
nvlist_free(optional);
}
+static void
+test_snapshot_list_batch(const char *dataset)
+{
+ nvlist_t *required = fnvlist_alloc();
+ nvlist_t *optional = fnvlist_alloc();
+ nvlist_t *props = fnvlist_alloc();
+ nvlist_t *input = fnvlist_alloc();
+
+ fnvlist_add_uint64(optional, SNAP_ITER_BATCH_CURSOR, 0);
+ fnvlist_add_uint64(optional, SNAP_ITER_MIN_TXG, 0);
+ fnvlist_add_uint64(optional, SNAP_ITER_MAX_TXG, UINT64_MAX);
+ fnvlist_add_boolean(props, "createtxg");
+ fnvlist_add_boolean(props, "creation");
+ fnvlist_add_boolean(props, "guid");
+ fnvlist_add_boolean(props, "inconsistent");
+ fnvlist_add_boolean(props, "numclones");
+ fnvlist_add_boolean(props, "redacted");
+ fnvlist_add_boolean(props, "used");
+ fnvlist_add_boolean(props, "referenced");
+ fnvlist_add_boolean(props, "logicalreferenced");
+ fnvlist_add_boolean(props, "defer_destroy");
+ fnvlist_add_boolean(props, "written");
+ fnvlist_add_boolean(props, "objsetid");
+ fnvlist_add_boolean(props, "userrefs");
+ fnvlist_add_uint64(required, SNAP_ITER_BATCH_MAX_RESULTS, 1024);
+ fnvlist_add_nvlist(required, SNAP_ITER_BATCH_PROPS, props);
+
+ IOC_INPUT_TEST(ZFS_IOC_SNAPSHOT_LIST_BATCH, dataset, required, optional,
+ 0);
+
+ fnvlist_free(props);
+ props = fnvlist_alloc();
+ fnvlist_add_nvlist(input, SNAP_ITER_BATCH_PROPS, props);
+ lzc_ioctl_run(ZFS_IOC_SNAPSHOT_LIST_BATCH, dataset, input,
+ ZFS_ERR_IOC_ARG_REQUIRED);
+ fnvlist_add_uint64(input, SNAP_ITER_BATCH_MAX_RESULTS, 1024);
+ lzc_ioctl_run(ZFS_IOC_SNAPSHOT_LIST_BATCH, dataset, input, 0);
+ fnvlist_remove(input, SNAP_ITER_BATCH_MAX_RESULTS);
+ fnvlist_add_uint64(input, SNAP_ITER_BATCH_MAX_RESULTS, 0);
+ lzc_ioctl_run(ZFS_IOC_SNAPSHOT_LIST_BATCH, dataset, input, EINVAL);
+ fnvlist_remove(input, SNAP_ITER_BATCH_MAX_RESULTS);
+ fnvlist_add_uint64(input, SNAP_ITER_BATCH_MAX_RESULTS, 1024);
+
+ fnvlist_add_boolean(props, "available");
+ fnvlist_remove(input, SNAP_ITER_BATCH_PROPS);
+ fnvlist_add_nvlist(input, SNAP_ITER_BATCH_PROPS, props);
+ lzc_ioctl_run(ZFS_IOC_SNAPSHOT_LIST_BATCH, dataset, input,
+ ZFS_ERR_IOC_ARG_UNAVAIL);
+
+ fnvlist_free(props);
+ props = fnvlist_alloc();
+ fnvlist_add_string(props, "guid", "bogus");
+ fnvlist_remove(input, SNAP_ITER_BATCH_PROPS);
+ fnvlist_add_nvlist(input, SNAP_ITER_BATCH_PROPS, props);
+ lzc_ioctl_run(ZFS_IOC_SNAPSHOT_LIST_BATCH, dataset, input,
+ ZFS_ERR_IOC_ARG_BADTYPE);
+
+ nvlist_free(input);
+ nvlist_free(props);
+ nvlist_free(optional);
+ nvlist_free(required);
+}
+
static void
test_destroy_bookmarks(const char *pool, const char *bookmark)
{
@@ -1029,6 +1092,7 @@ zfs_ioc_input_tests(const char *pool)
test_bookmark(pool, snapshot, bookmark);
test_get_bookmarks(dataset);
+ test_snapshot_list_batch(dataset);
test_get_bookmark_props(bookmark);
test_destroy_bookmarks(pool, bookmark);
@@ -1220,6 +1284,7 @@ validate_ioc_values(void)
CHECK(ZFS_IOC_BASE + 83 == ZFS_IOC_WAIT);
CHECK(ZFS_IOC_BASE + 84 == ZFS_IOC_WAIT_FS);
CHECK(ZFS_IOC_BASE + 87 == ZFS_IOC_POOL_SCRUB);
+ CHECK(ZFS_IOC_BASE + 91 == ZFS_IOC_SNAPSHOT_LIST_BATCH);
CHECK(ZFS_IOC_PLATFORM_BASE + 1 == ZFS_IOC_EVENTS_NEXT);
CHECK(ZFS_IOC_PLATFORM_BASE + 2 == ZFS_IOC_EVENTS_CLEAR);
CHECK(ZFS_IOC_PLATFORM_BASE + 3 == ZFS_IOC_EVENTS_SEEK);
diff --git a/tests/zfs-tests/cmd/snapshot_list_stats_test.c b/tests/zfs-tests/cmd/snapshot_list_stats_test.c
new file mode 100644
index 000000000000..1480ff4fcad5
--- /dev/null
+++ b/tests/zfs-tests/cmd/snapshot_list_stats_test.c
@@ -0,0 +1,213 @@
+// SPDX-License-Identifier: CDDL-1.0
+/*
+ * Verify projected snapshot-stat error handling against a real pool opened
+ * through libzpool. A link-time wrapper injects dmu_bonus_hold() errors for
+ * exact MOS objects, avoiding persistent pool damage and broad I/O faults.
+ */
+
+#include
+#include
+#include
+#include
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+static __thread objset_t *fault_objset;
+static __thread uint64_t fault_object;
+static __thread boolean_t fault_reached;
+
+int __real_dmu_bonus_hold(objset_t *, uint64_t, const void *, dmu_buf_t **);
+int __wrap_dmu_bonus_hold(objset_t *, uint64_t, const void *, dmu_buf_t **);
+
+/*
+ * Inject only the selected MOS object while preserving every other real read.
+ */
+int
+__wrap_dmu_bonus_hold(objset_t *os, uint64_t object, const void *tag,
+ dmu_buf_t **dbp)
+{
+ if (os == fault_objset && fault_object != 0 && object == fault_object) {
+ fault_reached = B_TRUE;
+ return (EIO);
+ }
+
+ return (__real_dmu_bonus_hold(os, object, tag, dbp));
+}
+
+static int
+get_file_info(dmu_object_type_t bonus_type, const void *data,
+ zfs_file_info_t *zoi)
+{
+ (void) bonus_type;
+ (void) data;
+ (void) zoi;
+ return (ENOENT);
+}
+
+static int
+check_snapshot_stats(dsl_pool_t *dp, uint64_t dsobj, const char *description,
+ uint64_t injected_object, uint64_t min_txg, uint64_t max_txg,
+ int expected_error, boolean_t expected_valid,
+ boolean_t expected_injection)
+{
+ dsl_dataset_snapshot_stats_t stats;
+ boolean_t injected;
+ int error;
+
+ fault_objset = dp->dp_meta_objset;
+ fault_object = injected_object;
+ fault_reached = B_FALSE;
+ error = dsl_dataset_snapshot_stats(dp, dsobj, B_FALSE, B_FALSE,
+ B_TRUE, min_txg, max_txg, &stats);
+ fault_objset = NULL;
+ fault_object = 0;
+ injected = fault_reached;
+
+ if (error != expected_error ||
+ stats.dss_written_valid != expected_valid ||
+ injected != expected_injection) {
+ (void) fprintf(stderr,
+ "%s: error=%d written_valid=%u injected=%u; expected "
+ "error=%d written_valid=%u injected=%u\n",
+ description, error, stats.dss_written_valid, injected,
+ expected_error, expected_valid, expected_injection);
+ return (-1);
+ }
+ return (0);
+}
+
+static int
+import_pool_readonly(char *pool, const char *search_path)
+{
+ char *paths[] = { (char *)search_path };
+ importargs_t args = { 0 };
+ libpc_handle_t lpch = {
+ .lpc_lib_handle = NULL,
+ .lpc_ops = &libzpool_config_ops,
+ .lpc_printerr = B_TRUE
+ };
+ nvlist_t *config = NULL;
+ nvlist_t *props = NULL;
+ int error;
+
+ args.paths = 1;
+ args.path = paths;
+ args.can_be_active = B_TRUE;
+ error = zpool_find_config(&lpch, pool, &config, &args);
+ if (error != 0)
+ return (error);
+
+ error = nvlist_alloc(&props, NV_UNIQUE_NAME, 0);
+ if (error == 0) {
+ error = nvlist_add_uint64(props,
+ zpool_prop_to_name(ZPOOL_PROP_READONLY), 1);
+ }
+ if (error == 0) {
+ error = spa_import(pool, config, props, ZFS_IMPORT_SKIP_MMP);
+ if (error == EEXIST)
+ error = 0;
+ }
+ nvlist_free(props);
+ nvlist_free(config);
+ return (error);
+}
+
+int
+main(int argc, char **argv)
+{
+ char pool[ZFS_MAX_DATASET_NAME_LEN];
+ dsl_dataset_t *ds = NULL;
+ dsl_pool_t *dp = NULL;
+ dsl_dataset_phys_t *dsp;
+ uint64_t dsobj, deadlist_obj, prev_obj, creation_txg;
+ char *separator;
+ int error = 0;
+
+ if (argc != 3) {
+ (void) fprintf(stderr,
+ "usage: %s snapshot search-path\n", argv[0]);
+ return (EXIT_FAILURE);
+ }
+ if (strlcpy(pool, argv[1], sizeof (pool)) >= sizeof (pool) ||
+ (separator = strpbrk(pool, "/@")) == NULL) {
+ (void) fprintf(stderr, "invalid snapshot name: %s\n", argv[1]);
+ return (EXIT_FAILURE);
+ }
+ *separator = '\0';
+
+ zfs_prop_init();
+ kernel_init(SPA_MODE_READ);
+ dmu_objset_register_type(DMU_OST_ZFS, get_file_info);
+
+ error = import_pool_readonly(pool, argv[2]);
+ if (error != 0) {
+ (void) fprintf(stderr, "cannot import %s: %s\n", pool,
+ strerror(error));
+ goto out;
+ }
+ error = dsl_pool_hold(argv[1], FTAG, &dp);
+ if (error != 0) {
+ (void) fprintf(stderr, "cannot hold %s: %s\n", pool,
+ strerror(error));
+ goto out;
+ }
+ error = dsl_dataset_hold(dp, argv[1], FTAG, &ds);
+ if (error != 0) {
+ (void) fprintf(stderr, "cannot hold %s: %s\n", argv[1],
+ strerror(error));
+ goto out;
+ }
+
+ dsp = dsl_dataset_phys(ds);
+ dsobj = ds->ds_object;
+ deadlist_obj = dsp->ds_deadlist_obj;
+ prev_obj = dsp->ds_prev_snap_obj;
+ creation_txg = dsp->ds_creation_txg;
+ dsl_dataset_rele(ds, FTAG);
+ ds = NULL;
+ if (deadlist_obj == 0 || prev_obj == 0) {
+ (void) fprintf(stderr, "%s lacks a deadlist or predecessor\n",
+ argv[1]);
+ error = EINVAL;
+ goto out;
+ }
+
+ if (check_snapshot_stats(dp, dsobj, "normal written lookup", 0, 0, 0,
+ 0, B_TRUE, B_FALSE) != 0 ||
+ check_snapshot_stats(dp, dsobj, "exact-TXG written lookup", 0,
+ creation_txg, creation_txg, 0, B_TRUE, B_FALSE) != 0 ||
+ check_snapshot_stats(dp, dsobj, "current deadlist EIO",
+ deadlist_obj, 0, 0, EIO, B_FALSE, B_TRUE) != 0 ||
+ check_snapshot_stats(dp, dsobj, "predecessor EIO", prev_obj, 0, 0,
+ 0, B_FALSE, B_TRUE) != 0 ||
+ check_snapshot_stats(dp, dsobj,
+ "below-minimum current deadlist EIO", deadlist_obj,
+ creation_txg + 1, 0, EIO, B_FALSE, B_TRUE) != 0 ||
+ check_snapshot_stats(dp, dsobj, "below-minimum predecessor EIO",
+ prev_obj, creation_txg + 1, 0, 0, B_FALSE, B_FALSE) != 0 ||
+ check_snapshot_stats(dp, dsobj,
+ "above-maximum current deadlist EIO", deadlist_obj, 0,
+ creation_txg - 1, EIO, B_FALSE, B_TRUE) != 0 ||
+ check_snapshot_stats(dp, dsobj, "above-maximum predecessor EIO",
+ prev_obj, 0, creation_txg - 1, 0, B_FALSE, B_FALSE) != 0) {
+ error = EPROTO;
+ }
+
+out:
+ if (ds != NULL)
+ dsl_dataset_rele(ds, FTAG);
+ if (dp != NULL)
+ dsl_pool_rele(dp, FTAG);
+ kernel_fini();
+ return (error == 0 ? EXIT_SUCCESS : EXIT_FAILURE);
+}
diff --git a/tests/zfs-tests/cmd/snapshot_list_test.c b/tests/zfs-tests/cmd/snapshot_list_test.c
new file mode 100644
index 000000000000..d835d2350467
--- /dev/null
+++ b/tests/zfs-tests/cmd/snapshot_list_test.c
@@ -0,0 +1,835 @@
+// SPDX-License-Identifier: CDDL-1.0
+/*
+ * Exercise projected snapshot iteration behavior which is not exposed by the
+ * zfs command line.
+ */
+
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include
+
+static int
+close_snapshot(zfs_handle_t *zhp, void *arg __attribute__((unused)))
+{
+ zfs_close(zhp);
+ return (0);
+}
+
+static int
+count_snapshot(zfs_handle_t *zhp, void *arg)
+{
+ unsigned int *count = arg;
+
+ (*count)++;
+ zfs_close(zhp);
+ return (0);
+}
+
+static int
+print_snapshot(zfs_handle_t *zhp, void *arg __attribute__((unused)))
+{
+ int error = 0;
+
+ if (puts(zfs_get_name(zhp)) == EOF)
+ error = EIO;
+ zfs_close(zhp);
+ return (error);
+}
+
+typedef struct callback_error_arg {
+ int cea_error;
+ unsigned int cea_calls;
+} callback_error_arg_t;
+
+typedef struct snapshot_metadata_arg {
+ zfs_type_t sma_underlying_type;
+ boolean_t sma_encrypted;
+ unsigned int sma_calls;
+} snapshot_metadata_arg_t;
+
+static int
+return_callback_error(zfs_handle_t *zhp, void *arg)
+{
+ callback_error_arg_t *callback = arg;
+
+ zfs_close(zhp);
+ callback->cea_calls++;
+ return (callback->cea_calls >= 2 ? callback->cea_error : 0);
+}
+
+static int
+collect_snapshot_metadata(zfs_handle_t *zhp, void *arg)
+{
+ snapshot_metadata_arg_t *metadata = arg;
+
+ metadata->sma_underlying_type = zfs_get_underlying_type(zhp);
+ metadata->sma_encrypted = zfs_is_encrypted(zhp);
+ metadata->sma_calls++;
+ zfs_close(zhp);
+ return (0);
+}
+
+static int
+check_encrypted_snapshot_metadata(zfs_handle_t *zhp, void *arg)
+{
+ unsigned int *callbacks = arg;
+ zfs_type_t underlying_type = zfs_get_underlying_type(zhp);
+ boolean_t encrypted = zfs_is_encrypted(zhp);
+ int error = 0;
+
+ (*callbacks)++;
+ if (underlying_type != ZFS_TYPE_FILESYSTEM || encrypted != B_TRUE) {
+ (void) fprintf(stderr,
+ "expected %s type/encryption %d/%d, got %d/%d\n",
+ zfs_get_name(zhp), ZFS_TYPE_FILESYSTEM, B_TRUE,
+ underlying_type, encrypted);
+ error = EPROTO;
+ }
+ zfs_close(zhp);
+ return (error);
+}
+
+static uint64_t
+parse_uint64(const char *value)
+{
+ char *end;
+ uint64_t result;
+
+ errno = 0;
+ result = strtoull(value, &end, 10);
+ if (errno != 0 || value[0] == '\0' || end[0] != '\0') {
+ (void) fprintf(stderr, "invalid integer: %s\n", value);
+ exit(EXIT_FAILURE);
+ }
+ return (result);
+}
+
+static int
+open_dataset(libzfs_handle_t **hdl, zfs_handle_t **zhp, const char *name)
+{
+ *hdl = libzfs_init();
+ if (*hdl == NULL) {
+ (void) fprintf(stderr, "libzfs_init failed\n");
+ return (EXIT_FAILURE);
+ }
+
+ *zhp = zfs_open(*hdl, name, ZFS_TYPE_FILESYSTEM | ZFS_TYPE_VOLUME);
+ if (*zhp == NULL) {
+ (void) fprintf(stderr, "cannot open %s\n", name);
+ libzfs_fini(*hdl);
+ return (EXIT_FAILURE);
+ }
+ return (EXIT_SUCCESS);
+}
+
+static int
+run_encrypted_snapshot_metadata(const char *name)
+{
+ libzfs_handle_t *hdl;
+ zfs_handle_t *zhp;
+ unsigned int callbacks = 0;
+ int error;
+
+ if (open_dataset(&hdl, &zhp, name) != 0)
+ return (EXIT_FAILURE);
+
+ error = zfs_iter_snapshots_v2(zhp, ZFS_ITER_BATCHED,
+ check_encrypted_snapshot_metadata, &callbacks, 0, 0);
+ zfs_close(zhp);
+ libzfs_fini(hdl);
+ if (error != 0 || callbacks == 0) {
+ (void) fprintf(stderr,
+ "expected encrypted projected snapshot callbacks, "
+ "got error %d and %u callbacks\n", error, callbacks);
+ return (EXIT_FAILURE);
+ }
+ return (EXIT_SUCCESS);
+}
+
+static int
+run_filter(const char *name, const char *minimum, const char *maximum)
+{
+ libzfs_handle_t *hdl;
+ zfs_handle_t *zhp;
+ int error;
+
+ if (open_dataset(&hdl, &zhp, name) != 0)
+ return (EXIT_FAILURE);
+
+ error = zfs_iter_snapshots_v2(zhp, ZFS_ITER_BATCHED |
+ ZFS_ITER_BATCHED_CREATETXG | ZFS_ITER_BATCHED_GUID |
+ ZFS_ITER_BATCHED_WRITTEN,
+ print_snapshot, NULL, parse_uint64(minimum), parse_uint64(maximum));
+ zfs_close(zhp);
+ libzfs_fini(hdl);
+ if (error != 0) {
+ (void) fprintf(stderr, "snapshot iteration failed: %s\n",
+ strerror(error));
+ return (EXIT_FAILURE);
+ }
+ return (EXIT_SUCCESS);
+}
+
+static int
+run_sorted(const char *name)
+{
+ libzfs_handle_t *hdl;
+ zfs_handle_t *zhp;
+ int error;
+
+ if (open_dataset(&hdl, &zhp, name) != 0)
+ return (EXIT_FAILURE);
+
+ error = zfs_iter_snapshots_sorted_v2(zhp, ZFS_ITER_BATCHED,
+ print_snapshot, NULL, 0, 0);
+ zfs_close(zhp);
+ libzfs_fini(hdl);
+ if (error != 0) {
+ (void) fprintf(stderr, "sorted snapshot iteration failed: %s\n",
+ strerror(error));
+ return (EXIT_FAILURE);
+ }
+ return (EXIT_SUCCESS);
+}
+
+static int
+run_dependents(const char *name, boolean_t simple)
+{
+ libzfs_handle_t *hdl;
+ zfs_handle_t *zhp;
+ int error, flags = ZFS_ITER_BATCHED;
+
+ if (open_dataset(&hdl, &zhp, name) != 0)
+ return (EXIT_FAILURE);
+
+ if (simple)
+ flags |= ZFS_ITER_SIMPLE;
+ error = zfs_iter_dependents_v2(zhp, flags, B_FALSE,
+ print_snapshot, NULL);
+ zfs_close(zhp);
+ libzfs_fini(hdl);
+ if (error != 0) {
+ (void) fprintf(stderr, "dependent iteration failed: %s\n",
+ strerror(error));
+ return (EXIT_FAILURE);
+ }
+ return (EXIT_SUCCESS);
+}
+
+static int
+run_interrupt(const char *name)
+{
+ libzfs_handle_t *hdl;
+ zfs_handle_t *zhp;
+ int error, iteration_errno;
+
+ if (open_dataset(&hdl, &zhp, name) != 0)
+ return (EXIT_FAILURE);
+
+ errno = 0;
+ error = zfs_iter_snapshots_v2(zhp, ZFS_ITER_BATCHED |
+ ZFS_ITER_BATCHED_CREATETXG | ZFS_ITER_BATCHED_GUID |
+ ZFS_ITER_BATCHED_CREATION | ZFS_ITER_BATCHED_USERREFS,
+ close_snapshot, NULL, 0, 0);
+ iteration_errno = errno;
+
+ zfs_close(zhp);
+ libzfs_fini(hdl);
+ if (error != -1 || iteration_errno != EINTR) {
+ (void) fprintf(stderr, "expected -1/EINTR, got %d/%d (%s)\n",
+ error, iteration_errno, error == 0 ? "success" :
+ strerror(iteration_errno));
+ return (EXIT_FAILURE);
+ }
+ return (EXIT_SUCCESS);
+}
+
+static int
+run_partial_error(const char *name, const char *mode,
+ const char *expected_value)
+{
+ libzfs_handle_t *hdl;
+ zfs_handle_t *zhp;
+ uint64_t expected_callbacks = parse_uint64(expected_value);
+ unsigned int callbacks = 0;
+ int flags, error, iteration_errno, libzfs_error;
+
+ if (strcmp(mode, "legacy") == 0) {
+ flags = 0;
+ } else if (strcmp(mode, "batched") == 0) {
+ flags = ZFS_ITER_BATCHED | ZFS_ITER_BATCHED_USERREFS;
+ } else {
+ (void) fprintf(stderr, "unknown iterator mode: %s\n", mode);
+ return (EXIT_FAILURE);
+ }
+ if (open_dataset(&hdl, &zhp, name) != 0)
+ return (EXIT_FAILURE);
+
+ libzfs_print_on_error(hdl, B_FALSE);
+ errno = 0;
+ error = zfs_iter_snapshots_v2(zhp, flags, count_snapshot, &callbacks,
+ 0, 0);
+ iteration_errno = errno;
+ libzfs_error = libzfs_errno(hdl);
+
+ zfs_close(zhp);
+ libzfs_fini(hdl);
+ if (error != -1 || iteration_errno != EIO ||
+ libzfs_error != EZFS_IO || callbacks != expected_callbacks) {
+ (void) fprintf(stderr,
+ "expected %s -1/%d/%d/%" PRIu64 ", got %d/%d/%d/%u\n",
+ mode, EIO, EZFS_IO, expected_callbacks, error,
+ iteration_errno, libzfs_error, callbacks);
+ return (EXIT_FAILURE);
+ }
+ return (EXIT_SUCCESS);
+}
+
+static int
+materialize_snapshot(zfs_handle_t *zhp, void *arg)
+{
+ unsigned int *callbacks = arg;
+ nvlist_t *props = zfs_get_all_props(zhp);
+ int materialize_errno;
+
+ if (props == NULL) {
+ materialize_errno = errno;
+ props = zfs_get_all_props(zhp);
+ if (props != NULL &&
+ nvlist_exists(props, zfs_prop_to_name(ZFS_PROP_CREATION)) &&
+ nvlist_exists(props, zfs_prop_to_name(ZFS_PROP_USERREFS))) {
+ (*callbacks)++;
+ }
+ zfs_close(zhp);
+ errno = materialize_errno;
+ return (-1);
+ }
+ zfs_close(zhp);
+ (*callbacks)++;
+ return (0);
+}
+
+static int
+run_handle_enomem(const char *name)
+{
+ libzfs_handle_t *hdl;
+ zfs_handle_t *zhp;
+ unsigned int callbacks = 0;
+ int error, iteration_errno, libzfs_error;
+
+ if (open_dataset(&hdl, &zhp, name) != 0)
+ return (EXIT_FAILURE);
+
+ libzfs_print_on_error(hdl, B_FALSE);
+ errno = 0;
+ error = zfs_iter_snapshots_v2(zhp, ZFS_ITER_BATCHED |
+ ZFS_ITER_BATCHED_CREATION | ZFS_ITER_BATCHED_USERREFS,
+ materialize_snapshot, &callbacks, 0, 0);
+ iteration_errno = errno;
+ libzfs_error = libzfs_errno(hdl);
+
+ zfs_close(zhp);
+ libzfs_fini(hdl);
+ if (error != -1 || iteration_errno != ENOMEM ||
+ libzfs_error != EZFS_NOMEM || callbacks != 1) {
+ (void) fprintf(stderr, "expected -1/%d/%d/1, got %d/%d/%d/%u\n",
+ ENOMEM, EZFS_NOMEM, error, iteration_errno, libzfs_error,
+ callbacks);
+ return (EXIT_FAILURE);
+ }
+ return (EXIT_SUCCESS);
+}
+
+static int
+check_property_getters(zfs_handle_t *zhp)
+{
+ char creation[ZFS_MAXPROPLEN], userrefs[ZFS_MAXPROPLEN];
+ uint64_t creation_value, userrefs_value;
+
+ creation_value = zfs_prop_get_int(zhp, ZFS_PROP_CREATION);
+ userrefs_value = zfs_prop_get_int(zhp, ZFS_PROP_USERREFS);
+ if (zfs_prop_get(zhp, ZFS_PROP_CREATION, creation,
+ sizeof (creation), NULL, NULL, 0, B_TRUE) != 0 ||
+ zfs_prop_get(zhp, ZFS_PROP_USERREFS, userrefs,
+ sizeof (userrefs), NULL, NULL, 0, B_TRUE) != 0 ||
+ parse_uint64(creation) != creation_value ||
+ parse_uint64(userrefs) != userrefs_value) {
+ return (EPROTO);
+ }
+ return (0);
+}
+
+static int
+check_direct_properties(zfs_handle_t *zhp, void *arg)
+{
+ unsigned int *callbacks = arg;
+ zfs_handle_t *copy = zfs_handle_dup(zhp);
+ int error;
+
+ zfs_close(zhp);
+ if (copy == NULL)
+ return (ENOMEM);
+ error = check_property_getters(copy);
+ zfs_close(copy);
+ if (error != 0)
+ return (error);
+
+ (*callbacks)++;
+ return (0);
+}
+
+static int
+check_pruned_property(zfs_handle_t *zhp, zfs_prop_t pruned_prop,
+ zfs_prop_t retained_prop)
+{
+ zfs_handle_t *copy = zfs_handle_dup(zhp);
+ uint8_t props_table[ZFS_NUM_PROPS];
+ nvlist_t *props;
+ int error = 0;
+
+ if (copy == NULL)
+ return (ENOMEM);
+ (void) memset(props_table, B_TRUE, sizeof (props_table));
+ props_table[pruned_prop] = B_FALSE;
+ zfs_prune_proplist(copy, props_table);
+ props = zfs_get_all_props(copy);
+ if (props == NULL ||
+ nvlist_exists(props, zfs_prop_to_name(pruned_prop)) ||
+ !nvlist_exists(props, zfs_prop_to_name(retained_prop))) {
+ error = EPROTO;
+ }
+ zfs_close(copy);
+ return (error);
+}
+
+static int
+check_materialized_properties(zfs_handle_t *zhp, void *arg)
+{
+ unsigned int *callbacks = arg;
+ nvlist_t *props, *creation_prop, *userrefs_prop;
+ uintptr_t clones;
+ uint64_t creation, userrefs;
+ int error = check_property_getters(zhp);
+
+ if (error != 0)
+ goto out;
+ error = check_pruned_property(zhp, ZFS_PROP_CREATION,
+ ZFS_PROP_USERREFS);
+ if (error != 0)
+ goto out;
+ error = check_pruned_property(zhp, ZFS_PROP_USERREFS,
+ ZFS_PROP_CREATION);
+ if (error != 0)
+ goto out;
+
+ clones = (uintptr_t)zfs_get_clones_nvl(zhp);
+ if (clones == 0) {
+ error = EPROTO;
+ goto out;
+ }
+ props = zfs_get_all_props(zhp);
+ if (props == NULL ||
+ nvlist_lookup_nvlist(props, zfs_prop_to_name(ZFS_PROP_CREATION),
+ &creation_prop) != 0 ||
+ nvlist_lookup_nvlist(props, zfs_prop_to_name(ZFS_PROP_USERREFS),
+ &userrefs_prop) != 0 ||
+ nvlist_lookup_uint64(creation_prop, ZPROP_VALUE, &creation) != 0 ||
+ nvlist_lookup_uint64(userrefs_prop, ZPROP_VALUE, &userrefs) != 0 ||
+ creation != zfs_prop_get_int(zhp, ZFS_PROP_CREATION) ||
+ userrefs != zfs_prop_get_int(zhp, ZFS_PROP_USERREFS) ||
+ clones != (uintptr_t)zfs_get_clones_nvl(zhp)) {
+ error = EPROTO;
+ }
+
+out:
+ zfs_close(zhp);
+ if (error == 0)
+ (*callbacks)++;
+ return (error);
+}
+
+static int
+check_refreshed_properties(zfs_handle_t *zhp, void *arg)
+{
+ zfs_refresh_properties(zhp);
+ return (check_materialized_properties(zhp, arg));
+}
+
+static int
+run_projected_properties(const char *name, boolean_t materialize,
+ boolean_t refresh)
+{
+ libzfs_handle_t *hdl;
+ zfs_handle_t *zhp;
+ unsigned int callbacks = 0;
+ int error;
+
+ if (open_dataset(&hdl, &zhp, name) != 0)
+ return (EXIT_FAILURE);
+
+ error = zfs_iter_snapshots_v2(zhp, ZFS_ITER_BATCHED |
+ ZFS_ITER_BATCHED_CREATION | ZFS_ITER_BATCHED_USERREFS,
+ refresh ? check_refreshed_properties :
+ (materialize ? check_materialized_properties :
+ check_direct_properties), &callbacks, 0, 0);
+
+ zfs_close(zhp);
+ libzfs_fini(hdl);
+ if (error != 0 || callbacks == 0) {
+ (void) fprintf(stderr, "expected successful projected "
+ "properties, got error/callbacks %d/%u\n",
+ error, callbacks);
+ return (EXIT_FAILURE);
+ }
+ return (EXIT_SUCCESS);
+}
+
+static int
+run_metadata_eproto(const char *name)
+{
+ libzfs_handle_t *hdl;
+ zfs_handle_t *zhp;
+ unsigned int callbacks = 0;
+ int error, iteration_errno;
+
+ if (open_dataset(&hdl, &zhp, name) != 0)
+ return (EXIT_FAILURE);
+
+ libzfs_print_on_error(hdl, B_FALSE);
+ errno = 0;
+ error = zfs_iter_snapshots_v2(zhp,
+ ZFS_ITER_BATCHED | ZFS_ITER_BATCHED_WRITTEN,
+ count_snapshot, &callbacks, 0, 0);
+ iteration_errno = errno;
+
+ zfs_close(zhp);
+ libzfs_fini(hdl);
+ if (error != -1 || iteration_errno != EPROTO || callbacks != 0) {
+ (void) fprintf(stderr, "expected -1/%d/0, got %d/%d/%u\n",
+ EPROTO, error, iteration_errno, callbacks);
+ return (EXIT_FAILURE);
+ }
+ return (EXIT_SUCCESS);
+}
+
+static int
+run_callback_error(const char *name, const char *error_name,
+ boolean_t bookmarks)
+{
+ libzfs_handle_t *hdl;
+ zfs_handle_t *zhp;
+ callback_error_arg_t callback = { 0 };
+ int error;
+
+ if (strcmp(error_name, "cmd_unavail") == 0)
+ callback.cea_error = ZFS_ERR_IOC_CMD_UNAVAIL;
+ else if (strcmp(error_name, "arg_unavail") == 0)
+ callback.cea_error = ZFS_ERR_IOC_ARG_UNAVAIL;
+ else if (strcmp(error_name, "enotty") == 0)
+ callback.cea_error = ENOTTY;
+ else if (strcmp(error_name, "enotsup") == 0)
+ callback.cea_error = ENOTSUP;
+ else if (strcmp(error_name, "enoent") == 0)
+ callback.cea_error = ENOENT;
+ else if (strcmp(error_name, "esrch") == 0)
+ callback.cea_error = ESRCH;
+ else {
+ (void) fprintf(stderr, "unknown callback error: %s\n",
+ error_name);
+ return (EXIT_FAILURE);
+ }
+
+ if (open_dataset(&hdl, &zhp, name) != 0)
+ return (EXIT_FAILURE);
+
+ if (bookmarks) {
+ error = zfs_iter_bookmarks_v2(zhp, ZFS_ITER_BATCHED,
+ return_callback_error, &callback);
+ } else {
+ error = zfs_iter_snapshots_v2(zhp, ZFS_ITER_BATCHED,
+ return_callback_error, &callback, 0, 0);
+ }
+ zfs_close(zhp);
+ libzfs_fini(hdl);
+ if (error != callback.cea_error || callback.cea_calls != 2) {
+ (void) fprintf(stderr, "expected callback result/calls %d/2, "
+ "got %d/%u\n", callback.cea_error, error,
+ callback.cea_calls);
+ return (EXIT_FAILURE);
+ }
+ return (EXIT_SUCCESS);
+}
+
+static int
+run_bookmark_error(const char *name, const char *error_name)
+{
+ libzfs_handle_t *hdl;
+ zfs_handle_t *zhp;
+ unsigned int callbacks = 0;
+ int expected, wrapper_error, legacy_error, batched_error;
+
+ if (strcmp(error_name, "eio") == 0)
+ expected = EIO;
+ else if (strcmp(error_name, "enoent") == 0)
+ expected = ENOENT;
+ else if (strcmp(error_name, "esrch") == 0)
+ expected = ESRCH;
+ else {
+ (void) fprintf(stderr, "unknown bookmark error: %s\n",
+ error_name);
+ return (EXIT_FAILURE);
+ }
+
+ if (open_dataset(&hdl, &zhp, name) != 0)
+ return (EXIT_FAILURE);
+
+ wrapper_error = zfs_iter_bookmarks(zhp, count_snapshot, &callbacks);
+ legacy_error = zfs_iter_bookmarks_v2(zhp, 0, count_snapshot,
+ &callbacks);
+ batched_error = zfs_iter_bookmarks_v2(zhp, ZFS_ITER_BATCHED,
+ count_snapshot, &callbacks);
+ zfs_close(zhp);
+ libzfs_fini(hdl);
+
+ if (wrapper_error != expected || legacy_error != expected ||
+ batched_error != expected || callbacks != 0) {
+ (void) fprintf(stderr, "expected bookmark errors/callbacks "
+ "%d/%d/%d/0, got %d/%d/%d/%u\n", expected, expected,
+ expected, wrapper_error, legacy_error, batched_error,
+ callbacks);
+ return (EXIT_FAILURE);
+ }
+ return (EXIT_SUCCESS);
+}
+
+static int
+run_stale_bookmarks(const char *name, const char *target)
+{
+ libzfs_handle_t *hdl;
+ zfs_handle_t *stale, *mutator = NULL;
+ unsigned int callbacks = 0;
+ const char *operation = target == NULL ? "destroy" : "rename";
+ int error, wrapper_error, legacy_error, batched_error;
+ int result = EXIT_FAILURE;
+
+ if (open_dataset(&hdl, &stale, name) != 0)
+ return (EXIT_FAILURE);
+
+ mutator = zfs_open(hdl, name, ZFS_TYPE_FILESYSTEM | ZFS_TYPE_VOLUME);
+ if (mutator == NULL) {
+ (void) fprintf(stderr, "cannot open second handle for %s\n",
+ name);
+ goto out;
+ }
+
+ if (target == NULL) {
+ error = zfs_destroy(mutator, B_FALSE);
+ } else {
+ renameflags_t flags = { .nounmount = B_TRUE };
+
+ error = zfs_rename(mutator, target, flags);
+ }
+ if (error != 0) {
+ (void) fprintf(stderr, "cannot %s %s: %s\n", operation, name,
+ libzfs_error_description(hdl));
+ goto out;
+ }
+ zfs_close(mutator);
+ mutator = NULL;
+
+ wrapper_error = zfs_iter_bookmarks(stale, count_snapshot, &callbacks);
+ legacy_error = zfs_iter_bookmarks_v2(stale, 0, count_snapshot,
+ &callbacks);
+ batched_error = zfs_iter_bookmarks_v2(stale, ZFS_ITER_BATCHED,
+ count_snapshot, &callbacks);
+ if (wrapper_error != ENOENT || legacy_error != ENOENT ||
+ batched_error != ENOENT || callbacks != 0) {
+ (void) fprintf(stderr,
+ "expected stale bookmark errors/callbacks "
+ "after %s to be %d/%d/%d/0, got %d/%d/%d/%u\n",
+ operation, ENOENT, ENOENT, ENOENT, wrapper_error,
+ legacy_error, batched_error, callbacks);
+ goto out;
+ }
+ result = EXIT_SUCCESS;
+
+out:
+ if (mutator != NULL)
+ zfs_close(mutator);
+ zfs_close(stale);
+ libzfs_fini(hdl);
+ return (result);
+}
+
+static int
+run_stale_snapshot_metadata(const char *name, const char *type_name)
+{
+ libzfs_handle_t *hdl;
+ zfs_handle_t *stale, *mutator = NULL;
+ nvlist_t *props = NULL;
+ snapshot_metadata_arg_t batched = { 0 }, legacy = { 0 };
+ zfs_type_t expected_type;
+ char snapname[ZFS_MAX_DATASET_NAME_LEN];
+ int batched_error, legacy_error, result = EXIT_FAILURE;
+
+ if (strcmp(type_name, "filesystem") == 0)
+ expected_type = ZFS_TYPE_FILESYSTEM;
+ else if (strcmp(type_name, "volume") == 0)
+ expected_type = ZFS_TYPE_VOLUME;
+ else {
+ (void) fprintf(stderr, "unknown replacement type: %s\n",
+ type_name);
+ return (EXIT_FAILURE);
+ }
+ if (open_dataset(&hdl, &stale, name) != 0)
+ return (EXIT_FAILURE);
+ if (zfs_get_underlying_type(stale) == expected_type &&
+ !zfs_is_encrypted(stale)) {
+ (void) fprintf(stderr, "%s does not exercise stale metadata\n",
+ name);
+ goto out;
+ }
+
+ mutator = zfs_open(hdl, name, ZFS_TYPE_FILESYSTEM | ZFS_TYPE_VOLUME);
+ if (mutator == NULL) {
+ (void) fprintf(stderr, "cannot open second handle for %s\n",
+ name);
+ goto out;
+ }
+ if (zfs_destroy(mutator, B_FALSE) != 0) {
+ (void) fprintf(stderr, "cannot destroy %s: %s\n", name,
+ libzfs_error_description(hdl));
+ goto out;
+ }
+ zfs_close(mutator);
+ mutator = NULL;
+
+ if (nvlist_alloc(&props, NV_UNIQUE_NAME, 0) != 0) {
+ (void) fprintf(stderr,
+ "cannot allocate replacement properties\n");
+ goto out;
+ }
+ if (expected_type == ZFS_TYPE_VOLUME &&
+ nvlist_add_uint64(props, zfs_prop_to_name(ZFS_PROP_VOLSIZE),
+ 64 * 1024 * 1024) != 0) {
+ (void) fprintf(stderr, "cannot set replacement volume size\n");
+ goto out;
+ }
+ if (zfs_create(hdl, name, expected_type, props) != 0) {
+ (void) fprintf(stderr, "cannot recreate %s: %s\n", name,
+ libzfs_error_description(hdl));
+ goto out;
+ }
+ if (snprintf(snapname, sizeof (snapname), "%s@replacement", name) >=
+ sizeof (snapname)) {
+ (void) fprintf(stderr, "snapshot name is too long: %s\n", name);
+ goto out;
+ }
+ if (zfs_snapshot(hdl, snapname, B_FALSE, NULL) != 0) {
+ (void) fprintf(stderr, "cannot snapshot %s: %s\n", name,
+ libzfs_error_description(hdl));
+ goto out;
+ }
+
+ batched_error = zfs_iter_snapshots_v2(stale, ZFS_ITER_BATCHED,
+ collect_snapshot_metadata, &batched, 0, 0);
+ legacy_error = zfs_iter_snapshots_v2(stale, 0,
+ collect_snapshot_metadata, &legacy, 0, 0);
+ if (batched_error != 0 || legacy_error != 0 || batched.sma_calls != 1 ||
+ legacy.sma_calls != 1 ||
+ batched.sma_underlying_type != expected_type ||
+ legacy.sma_underlying_type != expected_type ||
+ batched.sma_encrypted || legacy.sma_encrypted) {
+ (void) fprintf(stderr,
+ "expected errors/calls/types/encryption "
+ "0/0/1/1/%d/%d/%d/%d, got "
+ "%d/%d/%u/%u/%d/%d/%d/%d\n",
+ expected_type, expected_type, B_FALSE, B_FALSE,
+ batched_error, legacy_error,
+ batched.sma_calls, legacy.sma_calls,
+ batched.sma_underlying_type, legacy.sma_underlying_type,
+ batched.sma_encrypted, legacy.sma_encrypted);
+ goto out;
+ }
+ result = EXIT_SUCCESS;
+
+out:
+ nvlist_free(props);
+ if (mutator != NULL)
+ zfs_close(mutator);
+ zfs_close(stale);
+ libzfs_fini(hdl);
+ return (result);
+}
+
+int
+main(int argc, char **argv)
+{
+ if (argc == 5 && strcmp(argv[1], "filter") == 0)
+ return (run_filter(argv[2], argv[3], argv[4]));
+ if (argc == 3 && strcmp(argv[1], "sorted") == 0)
+ return (run_sorted(argv[2]));
+ if (argc == 3 && strcmp(argv[1], "dependents") == 0)
+ return (run_dependents(argv[2], B_FALSE));
+ if (argc == 3 && strcmp(argv[1], "dependents-simple") == 0)
+ return (run_dependents(argv[2], B_TRUE));
+ if (argc == 3 && strcmp(argv[1], "interrupt") == 0)
+ return (run_interrupt(argv[2]));
+ if (argc == 5 && strcmp(argv[1], "partial-error") == 0)
+ return (run_partial_error(argv[2], argv[3], argv[4]));
+ if (argc == 3 && strcmp(argv[1], "handle-enomem") == 0)
+ return (run_handle_enomem(argv[2]));
+ if (argc == 3 && strcmp(argv[1], "direct-properties") == 0)
+ return (run_projected_properties(argv[2], B_FALSE, B_FALSE));
+ if (argc == 3 && strcmp(argv[1], "materialized-properties") == 0)
+ return (run_projected_properties(argv[2], B_TRUE, B_FALSE));
+ if (argc == 3 && strcmp(argv[1], "refreshed-properties") == 0)
+ return (run_projected_properties(argv[2], B_FALSE, B_TRUE));
+ if (argc == 3 && strcmp(argv[1], "metadata-eproto") == 0)
+ return (run_metadata_eproto(argv[2]));
+ if (argc == 4 && strcmp(argv[1], "callback-error") == 0)
+ return (run_callback_error(argv[2], argv[3], B_FALSE));
+ if (argc == 4 && strcmp(argv[1], "bookmark-callback-error") == 0)
+ return (run_callback_error(argv[2], argv[3], B_TRUE));
+ if (argc == 4 && strcmp(argv[1], "bookmark-error") == 0)
+ return (run_bookmark_error(argv[2], argv[3]));
+ if (argc == 3 && strcmp(argv[1], "stale-bookmarks-destroy") == 0)
+ return (run_stale_bookmarks(argv[2], NULL));
+ if (argc == 4 && strcmp(argv[1], "stale-bookmarks-rename") == 0)
+ return (run_stale_bookmarks(argv[2], argv[3]));
+ if (argc == 4 && strcmp(argv[1], "stale-snapshot-metadata") == 0)
+ return (run_stale_snapshot_metadata(argv[2], argv[3]));
+ if (argc == 3 &&
+ strcmp(argv[1], "encrypted-snapshot-metadata") == 0)
+ return (run_encrypted_snapshot_metadata(argv[2]));
+
+ (void) fprintf(stderr, "usage: %s filter dataset min_txg max_txg\n"
+ " %s encrypted-snapshot-metadata dataset\n"
+ " %s sorted dataset\n"
+ " %s dependents dataset\n"
+ " %s dependents-simple dataset\n"
+ " %s interrupt dataset\n"
+ " %s partial-error dataset mode callbacks\n"
+ " %s handle-enomem dataset\n"
+ " %s direct-properties dataset\n"
+ " %s materialized-properties dataset\n"
+ " %s refreshed-properties dataset\n"
+ " %s metadata-eproto dataset\n"
+ " %s callback-error dataset error\n"
+ " %s bookmark-callback-error dataset error\n"
+ " %s bookmark-error dataset error\n"
+ " %s stale-bookmarks-destroy dataset\n"
+ " %s stale-bookmarks-rename dataset target\n"
+ " %s stale-snapshot-metadata dataset type\n",
+ argv[0], argv[0],
+ argv[0], argv[0], argv[0], argv[0], argv[0], argv[0], argv[0],
+ argv[0],
+ argv[0], argv[0], argv[0], argv[0], argv[0], argv[0], argv[0],
+ argv[0]);
+ return (EXIT_FAILURE);
+}
diff --git a/tests/zfs-tests/include/commands.cfg b/tests/zfs-tests/include/commands.cfg
index 7bd1ac915625..4985b3b2cdf7 100644
--- a/tests/zfs-tests/include/commands.cfg
+++ b/tests/zfs-tests/include/commands.cfg
@@ -228,6 +228,8 @@ export ZFSTEST_FILES_COMMON='badsend
rename_dir
rm_lnkcnt_zero_file
send_doall
+ snapshot_list_test
+ snapshot_list_stats_test
statx
threadsappend
user_ns_exec
diff --git a/tests/zfs-tests/include/tunables.cfg b/tests/zfs-tests/include/tunables.cfg
index 14c49c0b6a8e..cd996761f55c 100644
--- a/tests/zfs-tests/include/tunables.cfg
+++ b/tests/zfs-tests/include/tunables.cfg
@@ -91,6 +91,8 @@ SCAN_VDEV_LIMIT scan_vdev_limit zfs_scan_vdev_limit
SCRUB_AFTER_EXPAND scrub_after_expand zfs_scrub_after_expand
SEND_HOLES_WITHOUT_BIRTH_TIME send_holes_without_birth_time send_holes_without_birth_time
SLOW_IO_EVENTS_PER_SECOND slow_io_events_per_second zfs_slow_io_events_per_second
+SNAPSHOT_LIST_BATCH_SIZE snapshot_list_batch_size zfs_snapshot_list_batch_size
+SNAPSHOT_LIST_BATCH_TIME_US snapshot_list_batch_time_us zfs_snapshot_list_batch_time_us
SNAPSHOT_NO_SETUID UNSUPPORTED zfs_snapshot_no_setuid
SPA_ASIZE_INFLATION spa.asize_inflation spa_asize_inflation
SPA_DISCARD_MEMORY_LIMIT spa.discard_memory_limit zfs_spa_discard_memory_limit
diff --git a/tests/zfs-tests/tests/Makefile.am b/tests/zfs-tests/tests/Makefile.am
index a7c0881f2d81..8004ee10b0b2 100644
--- a/tests/zfs-tests/tests/Makefile.am
+++ b/tests/zfs-tests/tests/Makefile.am
@@ -828,6 +828,13 @@ nobase_dist_datadir_zfs_tests_tests_SCRIPTS += \
functional/cli_root/zfs_list/cleanup.ksh \
functional/cli_root/zfs_list/setup.ksh \
functional/cli_root/zfs_list/zfs_list_009_pos.ksh \
+ functional/cli_root/zfs_list/zfs_list_010_pos.ksh \
+ functional/cli_root/zfs_list/zfs_list_011_pos.ksh \
+ functional/cli_root/zfs_list/zfs_list_012_pos.ksh \
+ functional/cli_root/zfs_list/zfs_list_013_pos.ksh \
+ functional/cli_root/zfs_list/zfs_list_014_pos.ksh \
+ functional/cli_root/zfs_list/zfs_list_016_pos.ksh \
+ functional/cli_root/zfs_list/zfs_list_017_pos.ksh \
functional/cli_root/zfs_load-key/cleanup.ksh \
functional/cli_root/zfs_load-key/setup.ksh \
functional/cli_root/zfs_load-key/zfs_load-key_all.ksh \
diff --git a/tests/zfs-tests/tests/functional/cli_root/zfs_list/zfs_list_010_pos.ksh b/tests/zfs-tests/tests/functional/cli_root/zfs_list/zfs_list_010_pos.ksh
new file mode 100755
index 000000000000..c313e2b5940d
--- /dev/null
+++ b/tests/zfs-tests/tests/functional/cli_root/zfs_list/zfs_list_010_pos.ksh
@@ -0,0 +1,350 @@
+#!/bin/ksh -p
+# SPDX-License-Identifier: CDDL-1.0
+#
+
+. $STF_SUITE/include/libtest.shlib
+
+#
+# DESCRIPTION:
+# Batched snapshot and bookmark listing honors its tunable bounds and handles
+# projected objects across operational dataset states.
+#
+# STRATEGY:
+# 1. Verify the tunable bounds, then isolate the minimum time budget from the
+# batch-size limit and exercise cursor progress at the minimum batch size.
+# 2. Verify JSON preserves hidden creation-TXG metadata and integer fields.
+# 3. Verify bookmark projection after its source snapshot has been destroyed.
+# 4. Verify snapshots remain listable after an encrypted dataset's key unloads.
+# 5. Verify recursive mixed-type output combines simple, projected snapshot,
+# and projected bookmark handles without omissions, duplicates, or changed
+# ordering.
+# 6. Verify a held, deferred-destroy snapshot remains projected with userrefs.
+# 7. Verify listsnapshots=on implicitly includes projected snapshots.
+# 8. Verify a combined snapshot and bookmark list when only a bookmark exists.
+# 9. Verify nonrecursive combined-type listing stays on each explicitly named
+# dataset and does not broaden mixed-type lists.
+#
+
+verify_runnable "global"
+set -o pipefail
+
+DATASET="$TESTPOOL/$TESTFS/projected_list"
+ENCRYPTED_DATASET="$TESTPOOL/$TESTFS/projected_list_encrypted"
+MIXED_DATASET="$TESTPOOL/$TESTFS/projected_list_mixed"
+MIXED_CHILD="$MIXED_DATASET/child"
+MIXED_VOLUME="$MIXED_DATASET/vol"
+DATASET_MOUNT="$TESTDIR/projected_list"
+BATCH_OUTPUT="$TEST_BASE_DIR/projected_list_batch.$$"
+LEGACY_OUTPUT="$TEST_BASE_DIR/projected_list_legacy.$$"
+EXPECTED_OUTPUT="$TEST_BASE_DIR/projected_list_expected.$$"
+MARKER="$TEST_BASE_DIR/projected_list_marker.$$"
+COLUMNS="createtxg,creation,guid,name,written,type,userrefs,objsetid"
+HOLD_TAG="projected-list"
+DEFERRED_HOLD_TAG="projected-list-deferred"
+ENCRYPTED_HOLD_TAG="projected-list-encrypted"
+saved_listsnapshots=""
+
+function cleanup
+{
+ zfs release "$HOLD_TAG" "$DATASET@after_append" >/dev/null 2>&1
+ zfs release "$DEFERRED_HOLD_TAG" "$DATASET@deferred" \
+ >/dev/null 2>&1
+ zfs release "$ENCRYPTED_HOLD_TAG" "$ENCRYPTED_DATASET@second" \
+ >/dev/null 2>&1
+ [[ -n "$saved_listsnapshots" ]] && zpool set \
+ listsnapshots="$saved_listsnapshots" "$TESTPOOL" >/dev/null 2>&1
+ rm -f "$BATCH_OUTPUT" "$LEGACY_OUTPUT" "$EXPECTED_OUTPUT" "$MARKER"
+ datasetexists "$MIXED_DATASET" && zfs destroy -r "$MIXED_DATASET"
+ datasetexists "$ENCRYPTED_DATASET" && \
+ zfs destroy -r "$ENCRYPTED_DATASET"
+ datasetexists "$DATASET" && zfs destroy -r "$DATASET"
+ log_must restore_tunable SNAPSHOT_LIST_BATCH_TIME_US
+ log_must restore_tunable SNAPSHOT_LIST_BATCH_SIZE
+}
+
+function find_shim
+{
+ typeset helper helper_dir candidate
+
+ helper=$(readlink -f "$(command -v snapshot_list_test)")
+ helper_dir=${helper%/*}
+ for candidate in \
+ "$helper_dir/.libs/libsnapshot_list_test_shim.so" \
+ "$helper_dir/libsnapshot_list_test_shim.so" \
+ "$STF_SUITE/bin/libsnapshot_list_test_shim.so"; do
+ [[ -f "$candidate" ]] && print -- "$candidate" && return 0
+ done
+ return 1
+}
+
+function compare_projected_json_int
+{
+ typeset dataset="$1"
+ typeset object_types="$2"
+ typeset preload="$SHIM"
+ typeset -i batch_calls
+
+ [[ -n "$LD_PRELOAD" ]] && preload="$SHIM:$LD_PRELOAD"
+ log_must rm -f "$MARKER"
+ log_must eval "LD_PRELOAD='$preload' " \
+ "ZFS_SNAPSHOT_LIST_TEST_MODE='count' " \
+ "ZFS_SNAPSHOT_LIST_TEST_MARKER='$MARKER' " \
+ "zfs list -j --json-int -t '$object_types' -d 1 -o '$COLUMNS' " \
+ "'$dataset' > '$BATCH_OUTPUT'"
+ log_must grep -Fx count "$MARKER"
+ batch_calls=$(wc -l < "$MARKER")
+ (( batch_calls > 1 )) || log_fail \
+ "one-microsecond time budget did not split snapshot iteration"
+ log_must eval "zfs list -j --json-int -t '$object_types' -d 1 " \
+ "-s test:force-legacy-iterator -o '$COLUMNS' '$dataset' " \
+ "> '$LEGACY_OUTPUT'"
+ log_must diff "$LEGACY_OUTPUT" "$BATCH_OUTPUT"
+ log_must rm -f "$MARKER"
+}
+
+function compare_locked_encrypted
+{
+ typeset preload="$SHIM"
+
+ [[ -n "$LD_PRELOAD" ]] && preload="$SHIM:$LD_PRELOAD"
+ log_must rm -f "$MARKER"
+ log_must eval "LD_PRELOAD='$preload' " \
+ "ZFS_SNAPSHOT_LIST_TEST_MODE='count' " \
+ "ZFS_SNAPSHOT_LIST_TEST_MARKER='$MARKER' " \
+ "zfs list -H -p -t snapshot -o '$COLUMNS' " \
+ "'$ENCRYPTED_DATASET' > '$BATCH_OUTPUT'"
+ log_must grep -Fx count "$MARKER"
+ log_must eval "zfs list -H -p -t snapshot " \
+ "-s test:force-legacy-iterator -o '$COLUMNS' " \
+ "'$ENCRYPTED_DATASET' > '$LEGACY_OUTPUT'"
+ log_must diff "$LEGACY_OUTPUT" "$BATCH_OUTPUT"
+ log_must rm -f "$MARKER"
+}
+
+function compare_recursive_hybrid
+{
+ typeset object_types="filesystem,volume,snapshot,bookmark"
+ typeset columns="name,createtxg,guid,compression,quota,sharenfs"
+ typeset sort_options
+ typeset preload="$SHIM"
+
+ [[ -n "$LD_PRELOAD" ]] && preload="$SHIM:$LD_PRELOAD"
+ for sort_options in "" "-s quota" "-s sharenfs"; do
+ log_must rm -f "$MARKER"
+ log_must eval "LD_PRELOAD='$preload' " \
+ "ZFS_SNAPSHOT_LIST_TEST_MODE='count' " \
+ "ZFS_SNAPSHOT_LIST_TEST_MARKER='$MARKER' " \
+ "zfs list -H -p -r -t '$object_types' -o '$columns' " \
+ "$sort_options '$MIXED_DATASET' > '$BATCH_OUTPUT'"
+ log_must grep -Fx count "$MARKER"
+ log_must eval "zfs list -H -p -r -t '$object_types' " \
+ "-o '$columns' $sort_options " \
+ "-s test:force-legacy-iterator '$MIXED_DATASET' " \
+ "> '$LEGACY_OUTPUT'"
+ log_must diff "$LEGACY_OUTPUT" "$BATCH_OUTPUT"
+ done
+ log_must rm -f "$MARKER"
+}
+
+function verify_nonrecursive_mixed_scope
+{
+ typeset columns="name,createtxg,guid"
+ typeset preload="$SHIM"
+
+ [[ -n "$LD_PRELOAD" ]] && preload="$SHIM:$LD_PRELOAD"
+ log_must rm -f "$MARKER"
+ log_must eval "LD_PRELOAD='$preload' " \
+ "ZFS_SNAPSHOT_LIST_TEST_MODE='count' " \
+ "ZFS_SNAPSHOT_LIST_TEST_MARKER='$MARKER' " \
+ "zfs list -H -p -t snapshot,bookmark -d 1 -o '$columns' " \
+ "'$MIXED_DATASET' > '$BATCH_OUTPUT'"
+ log_must grep -Fx count "$MARKER"
+ printf "%s\n" "$MIXED_DATASET@root" "$MIXED_DATASET#root" | \
+ sort > "$EXPECTED_OUTPUT"
+ log_must eval "cut -f1 '$BATCH_OUTPUT' | sort > '$LEGACY_OUTPUT'"
+ log_must diff "$EXPECTED_OUTPUT" "$LEGACY_OUTPUT"
+
+ log_must eval "zfs list -H -p -t snapshot,bookmark -d 1 " \
+ "-o '$columns' '$MIXED_DATASET' '$MIXED_CHILD' " \
+ "> '$BATCH_OUTPUT'"
+ printf "%s\n" "$MIXED_DATASET@root" "$MIXED_DATASET#root" \
+ "$MIXED_CHILD@child" "$MIXED_CHILD#child" | \
+ sort > "$EXPECTED_OUTPUT"
+ log_must eval "cut -f1 '$BATCH_OUTPUT' | sort > '$LEGACY_OUTPUT'"
+ log_must diff "$EXPECTED_OUTPUT" "$LEGACY_OUTPUT"
+
+ log_must eval "zfs list -H -p -t filesystem,snapshot,bookmark " \
+ "-o name '$MIXED_DATASET' > '$BATCH_OUTPUT'"
+ printf "%s\n" "$MIXED_DATASET" > "$EXPECTED_OUTPUT"
+ log_must diff "$EXPECTED_OUTPUT" "$BATCH_OUTPUT"
+ log_must rm -f "$MARKER"
+}
+
+function compare_deferred_snapshot
+{
+ typeset preload="$SHIM"
+ typeset userrefs
+ typeset -i matches
+
+ [[ -n "$LD_PRELOAD" ]] && preload="$SHIM:$LD_PRELOAD"
+ log_must rm -f "$MARKER"
+ log_must eval "LD_PRELOAD='$preload' " \
+ "ZFS_SNAPSHOT_LIST_TEST_MODE='count' " \
+ "ZFS_SNAPSHOT_LIST_TEST_MARKER='$MARKER' " \
+ "zfs list -H -p -t snapshot -o '$COLUMNS' " \
+ "'$DATASET' > '$BATCH_OUTPUT'"
+ log_must grep -Fx count "$MARKER"
+ log_must eval "zfs list -H -p -t snapshot " \
+ "-s test:force-legacy-iterator -o '$COLUMNS' '$DATASET' " \
+ "> '$LEGACY_OUTPUT'"
+ log_must diff "$LEGACY_OUTPUT" "$BATCH_OUTPUT"
+
+ matches=$(awk -F '\t' -v target="$DATASET@deferred" \
+ '$4 == target { count++ } END { print count + 0 }' "$BATCH_OUTPUT")
+ (( matches == 1 )) || log_fail \
+ "deferred-destroy snapshot appeared $matches times; expected once"
+ userrefs=$(awk -F '\t' -v target="$DATASET@deferred" \
+ '$4 == target { print $7 }' "$BATCH_OUTPUT")
+ [[ "$userrefs" == "1" ]] || log_fail \
+ "deferred-destroy snapshot has userrefs=$userrefs; expected 1"
+ log_must rm -f "$MARKER"
+}
+
+function compare_implicit_snapshots
+{
+ typeset columns="name,createtxg,guid"
+ typeset preload="$SHIM"
+
+ [[ -n "$LD_PRELOAD" ]] && preload="$SHIM:$LD_PRELOAD"
+ saved_listsnapshots=$(get_pool_prop listsnapshots "$TESTPOOL")
+ log_must zpool set listsnapshots=on "$TESTPOOL"
+ log_must rm -f "$MARKER"
+ log_must eval "LD_PRELOAD='$preload' " \
+ "ZFS_SNAPSHOT_LIST_TEST_MODE='count' " \
+ "ZFS_SNAPSHOT_LIST_TEST_MARKER='$MARKER' " \
+ "zfs list -H -p -r -o '$columns' '$MIXED_DATASET' " \
+ "> '$BATCH_OUTPUT'"
+ log_must grep -Fx count "$MARKER"
+ log_must eval "zfs list -H -p -r -o '$columns' " \
+ "-s test:force-legacy-iterator '$MIXED_DATASET' " \
+ "> '$LEGACY_OUTPUT'"
+ log_must diff "$LEGACY_OUTPUT" "$BATCH_OUTPUT"
+
+ printf "%s\n" "$MIXED_DATASET" "$MIXED_DATASET@root" \
+ "$MIXED_CHILD" "$MIXED_CHILD@child" "$MIXED_VOLUME" \
+ "$MIXED_VOLUME@volume" | sort > "$EXPECTED_OUTPUT"
+ log_must eval "cut -f1 '$BATCH_OUTPUT' | sort > '$LEGACY_OUTPUT'"
+ log_must diff "$EXPECTED_OUTPUT" "$LEGACY_OUTPUT"
+ log_must rm -f "$MARKER"
+ log_must zpool set listsnapshots="$saved_listsnapshots" "$TESTPOOL"
+ saved_listsnapshots=""
+}
+
+function compare_bookmark_only_mixed_type
+{
+ typeset columns="name,createtxg,guid"
+ typeset preload="$SHIM"
+ typeset actual
+ typeset -i lines
+
+ [[ -n "$LD_PRELOAD" ]] && preload="$SHIM:$LD_PRELOAD"
+ log_must rm -f "$MARKER"
+ log_must eval "LD_PRELOAD='$preload' " \
+ "ZFS_SNAPSHOT_LIST_TEST_MODE='count' " \
+ "ZFS_SNAPSHOT_LIST_TEST_MARKER='$MARKER' " \
+ "zfs list -H -p -t snapshot,bookmark -d 1 -o '$columns' " \
+ "'$MIXED_CHILD' > '$BATCH_OUTPUT'"
+ log_must grep -Fx count "$MARKER"
+ log_must eval "zfs list -H -p -t snapshot,bookmark -d 1 " \
+ "-s test:force-legacy-iterator -o '$columns' '$MIXED_CHILD' " \
+ "> '$LEGACY_OUTPUT'"
+ log_must diff "$LEGACY_OUTPUT" "$BATCH_OUTPUT"
+
+ lines=$(wc -l < "$BATCH_OUTPUT")
+ (( lines == 1 )) || log_fail \
+ "bookmark-only mixed list returned $lines entries; expected one"
+ actual=$(cut -f1 "$BATCH_OUTPUT")
+ [[ "$actual" == "$MIXED_CHILD#child" ]] || log_fail \
+ "bookmark-only mixed list returned $actual"
+ log_must rm -f "$MARKER"
+}
+
+SHIM=$(find_shim) || log_unsupported "snapshot-list test shim not found"
+log_onexit cleanup
+log_assert "Projected listing matches legacy output across operational states."
+
+log_must save_tunable SNAPSHOT_LIST_BATCH_SIZE
+log_must save_tunable SNAPSHOT_LIST_BATCH_TIME_US
+log_mustnot set_tunable32 SNAPSHOT_LIST_BATCH_SIZE 0
+log_mustnot set_tunable32 SNAPSHOT_LIST_BATCH_TIME_US 0
+log_mustnot set_tunable32 SNAPSHOT_LIST_BATCH_SIZE 4097
+log_mustnot set_tunable32 SNAPSHOT_LIST_BATCH_TIME_US 100001
+log_must set_tunable32 SNAPSHOT_LIST_BATCH_SIZE 4096
+log_must set_tunable32 SNAPSHOT_LIST_BATCH_TIME_US 1
+
+log_must zfs create "$DATASET"
+log_must file_write -o create -f "$DATASET_MOUNT/payload" \
+ -b 131072 -c 8 -d R
+log_must zfs snapshot "$DATASET@base"
+log_must file_write -o append -f "$DATASET_MOUNT/payload" \
+ -b 131072 -c 4 -d R
+log_must zfs snapshot "$DATASET@after_append"
+log_must rm "$DATASET_MOUNT/payload"
+log_must zfs snapshot "$DATASET@after_remove"
+log_must zfs hold "$HOLD_TAG" "$DATASET@after_append"
+log_must zfs bookmark "$DATASET@base" "$DATASET#base"
+log_must zfs bookmark "$DATASET@after_append" "$DATASET#after_append"
+log_must zfs snapshot "$DATASET@bookmark_source"
+log_must zfs bookmark "$DATASET@bookmark_source" \
+ "$DATASET#source_destroyed"
+log_must zfs destroy "$DATASET@bookmark_source"
+snapexists "$DATASET@bookmark_source" && \
+ log_fail "bookmark source snapshot still exists"
+
+compare_projected_json_int "$DATASET" snapshot,bookmark
+log_must set_tunable32 SNAPSHOT_LIST_BATCH_SIZE 1
+log_must set_tunable32 SNAPSHOT_LIST_BATCH_TIME_US 100000
+
+log_must zfs snapshot "$DATASET@deferred"
+log_must zfs hold "$DEFERRED_HOLD_TAG" "$DATASET@deferred"
+log_must zfs destroy -d "$DATASET@deferred"
+defer_destroy=$(get_prop defer_destroy "$DATASET@deferred")
+[[ "$defer_destroy" == "on" ]] || log_fail \
+ "deferred snapshot has defer_destroy=$defer_destroy; expected on"
+compare_deferred_snapshot
+log_must zfs release "$DEFERRED_HOLD_TAG" "$DATASET@deferred"
+snapexists "$DATASET@deferred" && \
+ log_fail "deferred snapshot remained after its final hold was released"
+
+log_must eval "echo 'projected-list-password' | zfs create " \
+ "-o encryption=on -o keyformat=passphrase -o keylocation=prompt " \
+ "-o mountpoint=none '$ENCRYPTED_DATASET'"
+log_must zfs snapshot "$ENCRYPTED_DATASET@first"
+log_must zfs snapshot "$ENCRYPTED_DATASET@second"
+log_must zfs hold "$ENCRYPTED_HOLD_TAG" "$ENCRYPTED_DATASET@second"
+log_must snapshot_list_test encrypted-snapshot-metadata "$ENCRYPTED_DATASET"
+log_must zfs unload-key "$ENCRYPTED_DATASET"
+keystatus=$(get_prop keystatus "$ENCRYPTED_DATASET")
+[[ "$keystatus" == "unavailable" ]] || \
+ log_fail "encrypted dataset key remains $keystatus after unload"
+log_must snapshot_list_test encrypted-snapshot-metadata "$ENCRYPTED_DATASET"
+compare_locked_encrypted
+
+log_must zfs create -o mountpoint=none "$MIXED_DATASET"
+log_must zfs create -o mountpoint=none "$MIXED_CHILD"
+log_must zfs create -V 64M "$MIXED_VOLUME"
+log_must zfs snapshot "$MIXED_DATASET@root"
+log_must zfs snapshot "$MIXED_CHILD@child"
+log_must zfs snapshot "$MIXED_VOLUME@volume"
+log_must zfs bookmark "$MIXED_DATASET@root" "$MIXED_DATASET#root"
+log_must zfs bookmark "$MIXED_CHILD@child" "$MIXED_CHILD#child"
+log_must zfs bookmark "$MIXED_VOLUME@volume" "$MIXED_VOLUME#volume"
+verify_nonrecursive_mixed_scope
+compare_recursive_hybrid
+compare_implicit_snapshots
+log_must zfs destroy "$MIXED_CHILD@child"
+snapexists "$MIXED_CHILD@child" && \
+ log_fail "bookmark source snapshot still exists"
+compare_bookmark_only_mixed_type
+
+log_pass "Projected listing matches legacy output across operational states."
diff --git a/tests/zfs-tests/tests/functional/cli_root/zfs_list/zfs_list_011_pos.ksh b/tests/zfs-tests/tests/functional/cli_root/zfs_list/zfs_list_011_pos.ksh
new file mode 100755
index 000000000000..b30663bd22af
--- /dev/null
+++ b/tests/zfs-tests/tests/functional/cli_root/zfs_list/zfs_list_011_pos.ksh
@@ -0,0 +1,87 @@
+#!/bin/ksh -p
+# SPDX-License-Identifier: CDDL-1.0
+#
+
+. $STF_SUITE/include/libtest.shlib
+
+#
+# DESCRIPTION:
+# Batched snapshot listing handles iteration boundaries and ordering across
+# multiple result batches.
+#
+# STRATEGY:
+# 1. Create 1025 snapshots and verify direct iteration returns each exactly
+# once.
+# 2. Verify direct-libzfs ordering across multiple small batches.
+#
+
+verify_runnable "global"
+set -o pipefail
+
+BOUNDARY_DATASET="$TESTPOOL/$TESTFS/projected_boundary"
+SUBSET_DATASET="$TESTPOOL/$TESTFS/projected_subsets"
+BATCH_OUTPUT="$TEST_BASE_DIR/projected_boundary_batch.$$"
+FILTER_OUTPUT="$TEST_BASE_DIR/projected_boundary_filter.$$"
+EXPECTED_OUTPUT="$TEST_BASE_DIR/projected_boundary_expected.$$"
+
+function cleanup
+{
+ rm -f "$BATCH_OUTPUT" "$FILTER_OUTPUT" "$EXPECTED_OUTPUT"
+ datasetexists "$SUBSET_DATASET" && zfs destroy -r "$SUBSET_DATASET"
+ datasetexists "$BOUNDARY_DATASET" && zfs destroy -r "$BOUNDARY_DATASET"
+ log_must restore_tunable SNAPSHOT_LIST_BATCH_TIME_US
+ log_must restore_tunable SNAPSHOT_LIST_BATCH_SIZE
+}
+
+log_onexit cleanup
+log_assert "Batched snapshot listing handles boundaries and ordering."
+
+log_must save_tunable SNAPSHOT_LIST_BATCH_SIZE
+log_must save_tunable SNAPSHOT_LIST_BATCH_TIME_US
+log_must set_tunable32 SNAPSHOT_LIST_BATCH_SIZE 1024
+log_must set_tunable32 SNAPSHOT_LIST_BATCH_TIME_US 100000
+
+log_must zfs create "$BOUNDARY_DATASET"
+
+typeset -i index=0
+while (( index < 1025 )); do
+ suffix=$(printf "%04d" "$index")
+ log_must zfs snapshot "$BOUNDARY_DATASET@suffix_$suffix"
+ (( index += 1 ))
+done
+
+log_must eval "snapshot_list_test filter '$BOUNDARY_DATASET' 0 0 " \
+ "> '$FILTER_OUTPUT'"
+typeset -i callbacks
+callbacks=$(wc -l < "$FILTER_OUTPUT")
+(( callbacks == 1025 )) ||
+ log_fail "direct iteration delivered $callbacks callbacks; expected 1025"
+# Sorting preserves duplicate names, so this compares the callback multisets.
+log_must eval "sort '$FILTER_OUTPUT' > '$BATCH_OUTPUT'"
+log_must eval "zfs list -H -p -t snapshot " \
+ "-s test:force-legacy-iterator -o name '$BOUNDARY_DATASET' | " \
+ "sort > '$EXPECTED_OUTPUT'"
+log_must diff "$EXPECTED_OUTPUT" "$BATCH_OUTPUT"
+
+log_must set_tunable32 SNAPSHOT_LIST_BATCH_SIZE 2
+log_must zfs create "$SUBSET_DATASET"
+log_must zfs snapshot "$SUBSET_DATASET@m_oldest"
+log_must zfs snapshot "$SUBSET_DATASET@z_middle"
+log_must zfs snapshot "$SUBSET_DATASET@a_newest"
+oldest_txg=$(zfs get -H -p -o value createtxg \
+ "$SUBSET_DATASET@m_oldest")
+middle_txg=$(zfs get -H -p -o value createtxg \
+ "$SUBSET_DATASET@z_middle")
+newest_txg=$(zfs get -H -p -o value createtxg \
+ "$SUBSET_DATASET@a_newest")
+(( oldest_txg < middle_txg && middle_txg < newest_txg )) ||
+ log_fail "ordering snapshots do not have increasing creation TXGs"
+
+printf "%s\n" "$SUBSET_DATASET@m_oldest" "$SUBSET_DATASET@z_middle" \
+ "$SUBSET_DATASET@a_newest" > "$EXPECTED_OUTPUT"
+
+log_must eval "snapshot_list_test sorted '$SUBSET_DATASET' " \
+ "> '$BATCH_OUTPUT'"
+log_must diff "$EXPECTED_OUTPUT" "$BATCH_OUTPUT"
+
+log_pass "Batched snapshot listing handles boundaries and ordering."
diff --git a/tests/zfs-tests/tests/functional/cli_root/zfs_list/zfs_list_012_pos.ksh b/tests/zfs-tests/tests/functional/cli_root/zfs_list/zfs_list_012_pos.ksh
new file mode 100755
index 000000000000..1f5d347503a1
--- /dev/null
+++ b/tests/zfs-tests/tests/functional/cli_root/zfs_list/zfs_list_012_pos.ksh
@@ -0,0 +1,710 @@
+#!/bin/ksh -p
+# SPDX-License-Identifier: CDDL-1.0
+#
+
+. $STF_SUITE/include/libtest.shlib
+
+#
+# DESCRIPTION:
+# Projected snapshot and bookmark listing preserves ordering while handling
+# buffer growth, old kernels, interruption, and injected iterator errors.
+#
+# STRATEGY:
+# 1. Exercise synthetic and kernel-reported ENOMEM destination-buffer growth.
+# 2. Reject every batch ioctl with CMD_UNAVAIL, ENOTTY, or ENOTSUP and verify
+# automatic legacy fallback.
+# 3. Verify creation-TXG filters survive automatic legacy fallback.
+# 4. Reject a projected property and verify automatic legacy fallback.
+# 5. Verify written uses projected listing and -o all bypasses it.
+# 6. Exercise projected numeric values used only as sort keys.
+# 7. Exercise the same error paths with all projected columns and name alone.
+# 8. Verify callback values which collide with special ioctl errors are
+# returned unchanged by snapshot and bookmark iteration.
+# 9. Treat ioctl ENOENT and ESRCH before or after results as normal end.
+# 10. Inject EINTR and require libzfs to preserve it.
+# 11. Require direct projected properties and fail closed on materialization
+# ENOMEM.
+# 12. Reject an ioctl after one callback without fallback or callback replay.
+# 13. Continue after a valid empty, non-EOF batch with an advancing cursor.
+# 14. Verify filtered-out snapshots do not consume result batch slots.
+# 15. Verify old-kernel fallback preserves mixed snapshots and bookmarks,
+# including a bookmark whose source snapshot has been destroyed.
+# 16. Preserve bookmark iterator errors.
+# 17. Reuse stale parent names with different dataset types and encryption
+# states, and require projected handles to describe the replacements.
+# 18. Reject missing or invalid projected parent and written metadata with
+# EPROTO.
+# 19. Project cheap snapshot properties in batches, including aliases,
+# object IDs, written bytes, nonzero clone counts, and deferred destroy,
+# and preserve fallback.
+# 20. Read redacted state from a real redacted receive snapshot.
+# 21. Use projected listing for default snapshot and bookmark columns.
+# 22. Use full snapshot properties when coloring the available column.
+# 23. Batch built-ins which apply to neither snapshots nor bookmarks, including
+# as sort keys, while keeping pseudo-properties on legacy listing.
+#
+
+verify_runnable "global"
+set -o pipefail
+
+DATASET="$TESTPOOL/$TESTFS/projected_stress"
+CLONE_DATASET="$TESTPOOL/$TESTFS/projected_stress_clone"
+STALE_DESTROY_DATASET="$DATASET/stale_destroy"
+STALE_RENAME_DATASET="$DATASET/stale_rename"
+STALE_RENAMED_DATASET="$DATASET/stale_renamed"
+STALE_TYPE_DATASET="$DATASET/stale_type"
+STALE_ENCRYPTED_DATASET="$DATASET/stale_encrypted"
+REDACT_SOURCE="$TESTPOOL/$TESTFS/projected_redact_source"
+REDACT_CLONE="$TESTPOOL/$TESTFS/projected_redact_clone"
+REDACT_RECV="$TESTPOOL/$TESTFS/projected_redact_recv"
+BATCH_OUTPUT="$TEST_BASE_DIR/projected_stress_batch.$$"
+INJECTED_OUTPUT="$TEST_BASE_DIR/projected_stress_injected.$$"
+EXPECTED_OUTPUT="$TEST_BASE_DIR/projected_stress_expected.$$"
+MARKER="$TEST_BASE_DIR/projected_stress_marker.$$"
+KEY_FILE="$TEST_BASE_DIR/projected_stress_key.$$"
+COLUMNS="createtxg,creation,guid,name,written,type,userrefs,objsetid"
+INAPPLICABLE_PROPERTIES="checksum compression dedup recordsize quota"
+INAPPLICABLE_PROPERTIES="$INAPPLICABLE_PROPERTIES reservation readonly"
+INAPPLICABLE_PROPERTIES="$INAPPLICABLE_PROPERTIES canmount sharenfs"
+HOLD_TAG_ONE="projected-stress-one"
+HOLD_TAG_TWO="projected-stress-two"
+
+function cleanup
+{
+ if snapexists "$DATASET@z_middle"; then
+ zfs release "$HOLD_TAG_ONE" "$DATASET@z_middle" \
+ >/dev/null 2>&1
+ zfs release "$HOLD_TAG_TWO" "$DATASET@z_middle" \
+ >/dev/null 2>&1
+ fi
+ rm -f "$BATCH_OUTPUT" "$INJECTED_OUTPUT" "$EXPECTED_OUTPUT" \
+ "$MARKER" "$KEY_FILE"
+ datasetexists "$REDACT_RECV" && zfs destroy -r "$REDACT_RECV"
+ datasetexists "$REDACT_CLONE" && zfs destroy -r "$REDACT_CLONE"
+ datasetexists "$REDACT_SOURCE" && zfs destroy -r "$REDACT_SOURCE"
+ datasetexists "$CLONE_DATASET" && zfs destroy -r "$CLONE_DATASET"
+ datasetexists "$DATASET" && zfs destroy -r "$DATASET"
+ log_must restore_tunable SNAPSHOT_LIST_BATCH_TIME_US
+ log_must restore_tunable SNAPSHOT_LIST_BATCH_SIZE
+}
+
+function find_shim
+{
+ typeset helper helper_dir candidate
+
+ helper=$(readlink -f "$(command -v snapshot_list_test)")
+ helper_dir=${helper%/*}
+ for candidate in \
+ "$helper_dir/.libs/libsnapshot_list_test_shim.so" \
+ "$helper_dir/libsnapshot_list_test_shim.so" \
+ "$STF_SUITE/bin/libsnapshot_list_test_shim.so"; do
+ [[ -f "$candidate" ]] && print -- "$candidate" && return 0
+ done
+ return 1
+}
+
+function run_injected_list
+{
+ typeset mode="$1"
+ typeset output="$2"
+ typeset columns="${3:-$COLUMNS}"
+ typeset expected="${4:-$BATCH_OUTPUT}"
+ typeset sort_options="$5"
+ typeset object_types="${6:-snapshot}"
+ typeset preload="$SHIM"
+
+ [[ -n "$LD_PRELOAD" ]] && preload="$SHIM:$LD_PRELOAD"
+ log_must eval "LD_PRELOAD='$preload' " \
+ "ZFS_SNAPSHOT_LIST_TEST_MODE='$mode' " \
+ "ZFS_SNAPSHOT_LIST_TEST_MARKER='$MARKER' " \
+ "zfs list -H -p -t '$object_types' -o '$columns' " \
+ "$sort_options " \
+ "'$DATASET' " \
+ "> '$output'"
+ log_must grep -Fx "$mode" "$MARKER"
+ log_must diff "$expected" "$output"
+ log_must rm -f "$MARKER"
+}
+
+function verify_projected_written
+{
+ typeset preload="$SHIM"
+
+ [[ -n "$LD_PRELOAD" ]] && preload="$SHIM:$LD_PRELOAD"
+ log_must rm -f "$MARKER"
+ log_must eval "zfs list -H -p -t snapshot " \
+ "-s test:force-legacy-iterator -o name,written '$DATASET' " \
+ "> '$EXPECTED_OUTPUT'"
+ log_must eval "LD_PRELOAD='$preload' " \
+ "ZFS_SNAPSHOT_LIST_TEST_MODE='count' " \
+ "ZFS_SNAPSHOT_LIST_TEST_MARKER='$MARKER' " \
+ "zfs list -H -p -t snapshot -o name,written '$DATASET' " \
+ "> '$INJECTED_OUTPUT'"
+ log_must grep -Fx count "$MARKER"
+ log_must diff "$EXPECTED_OUTPUT" "$INJECTED_OUTPUT"
+ log_must rm -f "$MARKER"
+
+ for sort_options in "-s written" "-S written"; do
+ log_must eval "zfs list -H -p -t snapshot -o name " \
+ "$sort_options -s test:force-legacy-iterator " \
+ "'$DATASET' > '$EXPECTED_OUTPUT'"
+ log_must eval "LD_PRELOAD='$preload' " \
+ "ZFS_SNAPSHOT_LIST_TEST_MODE='count' " \
+ "ZFS_SNAPSHOT_LIST_TEST_MARKER='$MARKER' " \
+ "zfs list -H -p -t snapshot -o name $sort_options " \
+ "'$DATASET' > '$INJECTED_OUTPUT'"
+ log_must grep -Fx count "$MARKER"
+ log_must diff "$EXPECTED_OUTPUT" "$INJECTED_OUTPUT"
+ log_must rm -f "$MARKER"
+ done
+}
+
+function verify_unprojected_properties_use_legacy
+{
+ typeset property
+ typeset preload="$SHIM"
+
+ [[ -n "$LD_PRELOAD" ]] && preload="$SHIM:$LD_PRELOAD"
+ log_must rm -f "$MARKER"
+ log_must eval "zfs list -H -p -t snapshot,bookmark -d 1 -o all " \
+ "'$DATASET' > '$EXPECTED_OUTPUT'"
+ log_must eval "LD_PRELOAD='$preload' " \
+ "ZFS_SNAPSHOT_LIST_TEST_MODE='count' " \
+ "ZFS_SNAPSHOT_LIST_TEST_MARKER='$MARKER' " \
+ "zfs list -H -p -t snapshot,bookmark -d 1 -o all '$DATASET' " \
+ "> '$INJECTED_OUTPUT'"
+ log_must diff "$EXPECTED_OUTPUT" "$INJECTED_OUTPUT"
+ [[ ! -e "$MARKER" ]] ||
+ log_fail "-o all used projected listing"
+
+ for property in test:force-legacy-iterator userquota@0 \
+ written@m_oldest; do
+ log_must rm -f "$MARKER"
+ log_must eval "LD_PRELOAD='$preload' " \
+ "ZFS_SNAPSHOT_LIST_TEST_MODE='count' " \
+ "ZFS_SNAPSHOT_LIST_TEST_MARKER='$MARKER' " \
+ "zfs list -H -p -t snapshot -o 'name,$property' " \
+ "'$DATASET' > /dev/null"
+ [[ ! -e "$MARKER" ]] || log_fail \
+ "$property used projected listing"
+ done
+
+ log_must rm -f "$MARKER"
+ log_must eval "LD_PRELOAD='$preload' " \
+ "ZFS_SNAPSHOT_LIST_TEST_MODE='count' " \
+ "ZFS_SNAPSHOT_LIST_TEST_MARKER='$MARKER' " \
+ "zfs list -H -p -t snapshot -s test:force-legacy-iterator " \
+ "-o name '$DATASET' > /dev/null"
+ [[ ! -e "$MARKER" ]] || log_fail \
+ "user-property sort used projected listing"
+}
+
+function run_injected_interrupt
+{
+ typeset preload="$SHIM"
+
+ [[ -n "$LD_PRELOAD" ]] && preload="$SHIM:$LD_PRELOAD"
+ log_must eval "LD_PRELOAD='$preload' " \
+ "ZFS_SNAPSHOT_LIST_TEST_MODE='eintr' " \
+ "ZFS_SNAPSHOT_LIST_TEST_MARKER='$MARKER' " \
+ "snapshot_list_test interrupt '$DATASET'"
+ log_must grep -Fx eintr "$MARKER"
+ log_must rm -f "$MARKER"
+}
+
+function verify_direct_properties
+{
+ typeset preload="$SHIM"
+
+ [[ -n "$LD_PRELOAD" ]] && preload="$SHIM:$LD_PRELOAD"
+ log_must eval "LD_PRELOAD='$preload' " \
+ "ZFS_SNAPSHOT_LIST_TEST_MODE='direct_properties' " \
+ "ZFS_SNAPSHOT_LIST_TEST_MARKER='$MARKER' " \
+ "snapshot_list_test direct-properties '$DATASET'"
+ log_must grep -Fx direct_properties "$MARKER"
+ ! grep -q -Fx unexpected_property_nvlist "$MARKER" ||
+ log_fail "projected handle built per-property nvlists"
+ log_must rm -f "$MARKER"
+ log_must snapshot_list_test materialized-properties "$DATASET"
+ log_must eval "LD_PRELOAD='$preload' " \
+ "ZFS_SNAPSHOT_LIST_TEST_MODE='direct_properties' " \
+ "ZFS_SNAPSHOT_LIST_TEST_MARKER='$MARKER' " \
+ "snapshot_list_test refreshed-properties '$DATASET'"
+ ! grep -q -Fx unexpected_property_nvlist "$MARKER" ||
+ log_fail "refreshed handle retained projected properties"
+ log_must rm -f "$MARKER"
+}
+
+function run_injected_handle_enomem
+{
+ typeset preload="$SHIM"
+
+ [[ -n "$LD_PRELOAD" ]] && preload="$SHIM:$LD_PRELOAD"
+ log_must eval "LD_PRELOAD='$preload' " \
+ "ZFS_SNAPSHOT_LIST_TEST_MODE='handle_enomem' " \
+ "ZFS_SNAPSHOT_LIST_TEST_MARKER='$MARKER' " \
+ "snapshot_list_test handle-enomem '$DATASET'"
+ log_must grep -Fx handle_enomem "$MARKER"
+ log_must rm -f "$MARKER"
+}
+
+function run_injected_metadata_errors
+{
+ typeset mode preload="$SHIM"
+
+ [[ -n "$LD_PRELOAD" ]] && preload="$SHIM:$LD_PRELOAD"
+ for mode in missing_dmu_type invalid_dmu_type missing_dds_flags \
+ invalid_dds_flags missing_writtens missing_written_valid \
+ short_writtens short_written_valid invalid_written_valid; do
+ log_must rm -f "$MARKER"
+ log_must eval "LD_PRELOAD='$preload' " \
+ "ZFS_SNAPSHOT_LIST_TEST_MODE='$mode' " \
+ "ZFS_SNAPSHOT_LIST_TEST_MARKER='$MARKER' " \
+ "snapshot_list_test metadata-eproto '$DATASET'"
+ log_must grep -Fx "$mode" "$MARKER"
+ done
+ log_must rm -f "$MARKER"
+}
+
+function run_injected_late_failure
+{
+ typeset preload="$SHIM"
+ typeset -i lines
+
+ [[ -n "$LD_PRELOAD" ]] && preload="$SHIM:$LD_PRELOAD"
+ log_mustnot eval "LD_PRELOAD='$preload' " \
+ "ZFS_SNAPSHOT_LIST_TEST_MODE='enotsup_after_first' " \
+ "ZFS_SNAPSHOT_LIST_TEST_MARKER='$MARKER' " \
+ "snapshot_list_test filter '$DATASET' 0 0 > '$INJECTED_OUTPUT'"
+ log_must grep -Fx enotsup_after_first "$MARKER"
+ lines=$(wc -l < "$INJECTED_OUTPUT")
+ (( lines == 1 )) ||
+ log_fail "late batch failure delivered $lines snapshots; expected 1"
+ log_must rm -f "$MARKER"
+}
+
+function run_injected_empty_batch
+{
+ typeset preload="$SHIM"
+ typeset -i lines
+
+ [[ -n "$LD_PRELOAD" ]] && preload="$SHIM:$LD_PRELOAD"
+ log_must eval "LD_PRELOAD='$preload' " \
+ "ZFS_SNAPSHOT_LIST_TEST_MODE='empty_non_eof' " \
+ "ZFS_SNAPSHOT_LIST_TEST_MARKER='$MARKER' " \
+ "snapshot_list_test filter '$DATASET' 0 0 " \
+ "> '$INJECTED_OUTPUT'"
+ log_must grep -Fx empty_non_eof "$MARKER"
+ # The shim consumes one result to obtain the kernel's opaque next cursor.
+ lines=$(wc -l < "$INJECTED_OUTPUT")
+ (( lines == 2 )) ||
+ log_fail "iteration after an empty batch delivered $lines snapshots; " \
+ "expected 2"
+ log_must rm -f "$MARKER"
+}
+
+function run_injected_eof_after_first
+{
+ typeset mode
+ typeset preload="$SHIM"
+
+ [[ -n "$LD_PRELOAD" ]] && preload="$SHIM:$LD_PRELOAD"
+ log_must eval "snapshot_list_test filter '$DATASET' 0 0 " \
+ "> '$BATCH_OUTPUT'"
+ log_must eval "head -n 1 '$BATCH_OUTPUT' > '$EXPECTED_OUTPUT'"
+ [[ -s "$EXPECTED_OUTPUT" ]] ||
+ log_fail "unmodified iterator returned no first snapshot"
+
+ for mode in enoent_after_first esrch_after_first; do
+ log_must rm -f "$MARKER"
+ log_must eval "LD_PRELOAD='$preload' " \
+ "ZFS_SNAPSHOT_LIST_TEST_MODE='$mode' " \
+ "ZFS_SNAPSHOT_LIST_TEST_MARKER='$MARKER' " \
+ "snapshot_list_test filter '$DATASET' 0 0 " \
+ "> '$INJECTED_OUTPUT'"
+ log_must grep -Fx "$mode" "$MARKER"
+ log_must diff "$EXPECTED_OUTPUT" "$INJECTED_OUTPUT"
+ log_must rm -f "$MARKER"
+ done
+}
+
+function run_injected_bookmark_errors
+{
+ typeset error mode preload="$SHIM"
+ typeset -i calls
+
+ [[ -n "$LD_PRELOAD" ]] && preload="$SHIM:$LD_PRELOAD"
+ for error in eio enoent esrch; do
+ mode="bookmark_$error"
+ log_must rm -f "$MARKER"
+ log_must eval "LD_PRELOAD='$preload' " \
+ "ZFS_SNAPSHOT_LIST_TEST_MODE='$mode' " \
+ "ZFS_SNAPSHOT_LIST_TEST_MARKER='$MARKER' " \
+ "snapshot_list_test bookmark-error '$DATASET' '$error'"
+ calls=$(grep -Fxc "$mode" "$MARKER")
+ (( calls == 3 )) ||
+ log_fail "$mode was injected $calls times; expected 3"
+ done
+ log_must rm -f "$MARKER"
+}
+
+function verify_stale_bookmark_handles
+{
+ log_must zfs create -o mountpoint=none "$STALE_DESTROY_DATASET"
+ log_must snapshot_list_test stale-bookmarks-destroy \
+ "$STALE_DESTROY_DATASET"
+ datasetexists "$STALE_DESTROY_DATASET" &&
+ log_fail "stale-handle destroy left $STALE_DESTROY_DATASET"
+
+ log_must zfs create -o mountpoint=none "$STALE_RENAME_DATASET"
+ log_must snapshot_list_test stale-bookmarks-rename \
+ "$STALE_RENAME_DATASET" "$STALE_RENAMED_DATASET"
+ datasetexists "$STALE_RENAME_DATASET" &&
+ log_fail "stale-handle rename left $STALE_RENAME_DATASET"
+ datasetexists "$STALE_RENAMED_DATASET" ||
+ log_fail "stale-handle rename did not create $STALE_RENAMED_DATASET"
+ log_must zfs destroy "$STALE_RENAMED_DATASET"
+}
+
+function verify_stale_snapshot_metadata
+{
+ log_must zfs create -o mountpoint=none "$STALE_TYPE_DATASET"
+ log_must snapshot_list_test stale-snapshot-metadata \
+ "$STALE_TYPE_DATASET" volume
+ destroy_dataset "$STALE_TYPE_DATASET" -r
+
+ print 'projected-listing-passphrase' > "$KEY_FILE"
+ log_must chmod 600 "$KEY_FILE"
+ log_must zfs create -o mountpoint=none -o encryption=on \
+ -o keyformat=passphrase -o keylocation="file://$KEY_FILE" \
+ "$STALE_ENCRYPTED_DATASET"
+ log_must snapshot_list_test stale-snapshot-metadata \
+ "$STALE_ENCRYPTED_DATASET" filesystem
+ destroy_dataset "$STALE_ENCRYPTED_DATASET" -r
+ log_must rm -f "$KEY_FILE"
+}
+
+function verify_filtered_fallback
+{
+ typeset target="$DATASET@a_newest"
+ typeset txg preload="$SHIM"
+
+ [[ -n "$LD_PRELOAD" ]] && preload="$SHIM:$LD_PRELOAD"
+ txg=$(zfs get -H -p -o value createtxg "$target")
+ log_must eval "LD_PRELOAD='$preload' " \
+ "ZFS_SNAPSHOT_LIST_TEST_MODE='cmd_unavail' " \
+ "ZFS_SNAPSHOT_LIST_TEST_MARKER='$MARKER' " \
+ "snapshot_list_test filter '$DATASET' '$txg' '$txg' " \
+ "> '$INJECTED_OUTPUT'"
+ log_must grep -Fx cmd_unavail "$MARKER"
+ [[ "$(<"$INJECTED_OUTPUT")" == "$target" ]] ||
+ log_fail "legacy fallback did not preserve exact creation-TXG filter"
+ log_must rm -f "$MARKER"
+}
+
+function verify_projected_sort
+{
+ typeset sort_options="$1"
+
+ log_must eval "zfs list -H -p -t snapshot -o name " \
+ "$sort_options -s test:force-legacy-iterator '$DATASET' " \
+ "> '$EXPECTED_OUTPUT'"
+ run_injected_list count "$INJECTED_OUTPUT" name "$EXPECTED_OUTPUT" \
+ "$sort_options"
+}
+
+function verify_projected_userrefs
+{
+ log_must eval "zfs list -H -p -t snapshot " \
+ "-s test:force-legacy-iterator -o name,userrefs '$DATASET' " \
+ "> '$EXPECTED_OUTPUT'"
+ run_injected_list count "$INJECTED_OUTPUT" name,userrefs \
+ "$EXPECTED_OUTPUT"
+}
+
+function verify_default_property_lists
+{
+ typeset object_types
+ typeset preload="$SHIM"
+
+ [[ -n "$LD_PRELOAD" ]] && preload="$SHIM:$LD_PRELOAD"
+ for object_types in snapshot snapshot,bookmark; do
+ log_must eval "zfs list -H -p -t '$object_types' -d 1 " \
+ "-s test:force-legacy-iterator " \
+ "-o name,used,available,referenced,mountpoint " \
+ "'$DATASET' > '$EXPECTED_OUTPUT'"
+ log_must eval "LD_PRELOAD='$preload' " \
+ "ZFS_SNAPSHOT_LIST_TEST_MODE='count' " \
+ "ZFS_SNAPSHOT_LIST_TEST_MARKER='$MARKER' " \
+ "zfs list -H -p -t '$object_types' -d 1 '$DATASET' " \
+ "> '$INJECTED_OUTPUT'"
+ log_must grep -Fx count "$MARKER"
+ log_must diff "$EXPECTED_OUTPUT" "$INJECTED_OUTPUT"
+ log_must rm -f "$MARKER"
+ done
+
+ log_must eval "zfs list -H -p -t bookmark " \
+ "-s test:force-legacy-iterator " \
+ "-o name,used,available,referenced,mountpoint '$DATASET' " \
+ "> '$EXPECTED_OUTPUT'"
+ log_must eval "LD_PRELOAD='$preload' " \
+ "ZFS_SNAPSHOT_LIST_TEST_MODE='bookmark_projected' " \
+ "ZFS_SNAPSHOT_LIST_TEST_MARKER='$MARKER' " \
+ "zfs list -H -p -t bookmark '$DATASET' > '$INJECTED_OUTPUT'"
+ log_must grep -Fx bookmark_projected "$MARKER"
+ ! grep -q -Fx bookmark_not_projected "$MARKER" ||
+ log_fail "default bookmark listing requested excess properties"
+ log_must diff "$EXPECTED_OUTPUT" "$INJECTED_OUTPUT"
+ log_must rm -f "$MARKER"
+}
+
+function verify_colored_snapshot_listing_uses_legacy
+{
+ typeset object_types
+ typeset preload="$SHIM"
+
+ [[ -n "$LD_PRELOAD" ]] && preload="$SHIM:$LD_PRELOAD"
+ for object_types in snapshot snapshot,bookmark; do
+ log_must rm -f "$MARKER"
+ log_must eval "faketty TERM=xterm-256color ZFS_COLOR=1 " \
+ "zfs list -t '$object_types' -d 1 " \
+ "-s test:force-legacy-iterator '$DATASET' " \
+ "> '$EXPECTED_OUTPUT'"
+ log_must eval "faketty TERM=xterm-256color ZFS_COLOR=1 " \
+ "LD_PRELOAD='$preload' " \
+ "ZFS_SNAPSHOT_LIST_TEST_MODE='count' " \
+ "ZFS_SNAPSHOT_LIST_TEST_MARKER='$MARKER' " \
+ "zfs list -t '$object_types' -d 1 '$DATASET' " \
+ "> '$INJECTED_OUTPUT'"
+ [[ ! -e "$MARKER" ]] || log_fail \
+ "colored $object_types listing used projected properties"
+ log_must diff "$EXPECTED_OUTPUT" "$INJECTED_OUTPUT"
+ done
+}
+
+function verify_projected_property_union
+{
+ typeset columns="name,used,available,referenced,refer,mountpoint"
+ typeset property
+ typeset numclones
+ columns="$columns,logicalused,lused,logicalreferenced,lrefer"
+ columns="$columns,defer_destroy,numclones,objsetid"
+ columns="$columns,inconsistent,redacted,origin,written"
+ for property in $INAPPLICABLE_PROPERTIES; do
+ columns="$columns,$property"
+ done
+
+ log_must eval "zfs list -H -p -t snapshot " \
+ "-s test:force-legacy-iterator -o '$columns' '$DATASET' " \
+ "> '$EXPECTED_OUTPUT'"
+ run_injected_list count "$INJECTED_OUTPUT" "$columns" \
+ "$EXPECTED_OUTPUT"
+ run_injected_list arg_unavail "$INJECTED_OUTPUT" "$columns" \
+ "$EXPECTED_OUTPUT"
+
+ for property in used available referenced refer mountpoint logicalused \
+ lused logicalreferenced lrefer defer_destroy numclones inconsistent \
+ redacted origin written objsetid $INAPPLICABLE_PROPERTIES; do
+ log_must eval "zfs list -H -p -t snapshot " \
+ "-s '$property' -s test:force-legacy-iterator " \
+ "-o name '$DATASET' > '$EXPECTED_OUTPUT'"
+ run_injected_list count "$INJECTED_OUTPUT" name \
+ "$EXPECTED_OUTPUT" "-s $property"
+ done
+
+ numclones=$(zfs get -H -p -o value numclones \
+ "$DATASET@m_oldest")
+ (( numclones == 1 )) ||
+ log_fail "projected numclones is $numclones; expected 1"
+}
+
+function verify_inapplicable_property_types
+{
+ typeset columns="name,logicalused,lused"
+ typeset object_types property
+ for property in $INAPPLICABLE_PROPERTIES; do
+ columns="$columns,$property"
+ done
+
+ for object_types in snapshot snapshot,bookmark; do
+ log_must eval "zfs list -H -p -t '$object_types' -d 1 " \
+ "-s test:force-legacy-iterator -o '$columns' '$DATASET' " \
+ "> '$EXPECTED_OUTPUT'"
+ run_injected_list count "$INJECTED_OUTPUT" "$columns" \
+ "$EXPECTED_OUTPUT" "-d 1" "$object_types"
+ done
+
+ log_must eval "zfs list -H -p -t bookmark " \
+ "-s test:force-legacy-iterator -o '$columns' '$DATASET' " \
+ "> '$EXPECTED_OUTPUT'"
+ run_injected_list bookmark_empty_projected "$INJECTED_OUTPUT" \
+ "$columns" "$EXPECTED_OUTPUT" "" bookmark
+}
+
+function verify_projected_redacted
+{
+ typeset columns="name,numclones,inconsistent,redacted,origin"
+ typeset preload="$SHIM"
+ typeset redacted
+
+ [[ -n "$LD_PRELOAD" ]] && preload="$SHIM:$LD_PRELOAD"
+ log_must zfs create -o mountpoint=none "$REDACT_SOURCE"
+ log_must zfs snapshot "$REDACT_SOURCE@base"
+ log_must zfs clone -o mountpoint=none "$REDACT_SOURCE@base" \
+ "$REDACT_CLONE"
+ log_must zfs snapshot "$REDACT_CLONE@mask"
+ log_must zfs redact "$REDACT_SOURCE@base" book \
+ "$REDACT_CLONE@mask"
+ log_must eval "zfs send --redact book '$REDACT_SOURCE@base' | " \
+ "zfs receive -u '$REDACT_RECV'"
+
+ log_must eval "zfs list -H -p -t snapshot " \
+ "-s test:force-legacy-iterator -o '$columns' '$REDACT_RECV' " \
+ "> '$EXPECTED_OUTPUT'"
+ log_must eval "LD_PRELOAD='$preload' " \
+ "ZFS_SNAPSHOT_LIST_TEST_MODE='count' " \
+ "ZFS_SNAPSHOT_LIST_TEST_MARKER='$MARKER' " \
+ "zfs list -H -p -t snapshot -o '$columns' " \
+ "'$REDACT_RECV' > '$INJECTED_OUTPUT'"
+ log_must grep -Fx count "$MARKER"
+ log_must diff "$EXPECTED_OUTPUT" "$INJECTED_OUTPUT"
+ redacted=$(awk -F '\t' '{ print $4 }' "$INJECTED_OUTPUT")
+ [[ "$redacted" == "1" ]] ||
+ log_fail "projected redacted is $redacted; expected 1"
+ log_must rm -f "$MARKER"
+}
+
+function verify_filtered_batch_fill
+{
+ typeset target="$DATASET@a_newest"
+ typeset txg preload="$SHIM"
+ typeset -i calls
+
+ [[ -n "$LD_PRELOAD" ]] && preload="$SHIM:$LD_PRELOAD"
+ txg=$(zfs get -H -p -o value createtxg "$target")
+ log_must eval "LD_PRELOAD='$preload' " \
+ "ZFS_SNAPSHOT_LIST_TEST_MODE='count' " \
+ "ZFS_SNAPSHOT_LIST_TEST_MARKER='$MARKER' " \
+ "snapshot_list_test filter '$DATASET' '$txg' '$txg' " \
+ "> '$INJECTED_OUTPUT'"
+ [[ "$(<"$INJECTED_OUTPUT")" == "$target" ]] ||
+ log_fail "filtered batch did not return only $target"
+ calls=$(wc -l < "$MARKER")
+ (( calls == 1 )) ||
+ log_fail "filtered result required $calls batches; expected 1"
+ log_must rm -f "$MARKER"
+}
+
+function verify_mixed_type_fallback
+{
+ typeset columns="name,createtxg,guid"
+
+ log_must eval "zfs list -H -p -t snapshot,bookmark -d 1 " \
+ "-s test:force-legacy-iterator -o '$columns' '$DATASET' " \
+ "> '$EXPECTED_OUTPUT'"
+ run_injected_list cmd_unavail "$INJECTED_OUTPUT" "$columns" \
+ "$EXPECTED_OUTPUT" "-d 1" snapshot,bookmark
+
+ printf "%s\n" "$DATASET@m_oldest" "$DATASET@z_middle" \
+ "$DATASET@a_newest" "$DATASET#normal" \
+ "$DATASET#source_destroyed" | sort > "$EXPECTED_OUTPUT"
+ log_must eval "cut -f1 '$INJECTED_OUTPUT' | sort > '$BATCH_OUTPUT'"
+ log_must diff "$EXPECTED_OUTPUT" "$BATCH_OUTPUT"
+ log_must rm -f "$EXPECTED_OUTPUT"
+}
+
+SHIM=$(find_shim) || log_unsupported "snapshot-list test shim not found"
+log_onexit cleanup
+log_assert "Projected listing preserves ordering through error paths."
+
+log_must save_tunable SNAPSHOT_LIST_BATCH_SIZE
+log_must save_tunable SNAPSHOT_LIST_BATCH_TIME_US
+log_must set_tunable32 SNAPSHOT_LIST_BATCH_SIZE 1024
+log_must set_tunable32 SNAPSHOT_LIST_BATCH_TIME_US 100000
+
+log_must zfs create "$DATASET"
+log_must zfs snapshot "$DATASET@m_oldest"
+log_must zfs snapshot "$DATASET@z_middle"
+log_must zfs snapshot "$DATASET@a_newest"
+log_must zfs hold "$HOLD_TAG_ONE" "$DATASET@z_middle"
+log_must zfs hold "$HOLD_TAG_TWO" "$DATASET@z_middle"
+log_must zfs destroy -d "$DATASET@z_middle"
+snapexists "$DATASET@z_middle" ||
+ log_fail "deferred-destroy snapshot disappeared while held"
+log_must zfs bookmark "$DATASET@m_oldest" "$DATASET#normal"
+log_must zfs snapshot "$DATASET@bookmark_source"
+log_must zfs bookmark "$DATASET@bookmark_source" \
+ "$DATASET#source_destroyed"
+log_must zfs destroy "$DATASET@bookmark_source"
+snapexists "$DATASET@bookmark_source" && \
+ log_fail "bookmark source snapshot still exists"
+verify_stale_bookmark_handles
+verify_stale_snapshot_metadata
+verify_projected_written
+verify_unprojected_properties_use_legacy
+run_injected_bookmark_errors
+verify_projected_userrefs
+verify_default_property_lists
+verify_inapplicable_property_types
+verify_colored_snapshot_listing_uses_legacy
+verify_projected_sort "-s guid"
+verify_projected_sort "-S guid"
+verify_projected_sort "-s userrefs"
+verify_projected_sort "-S userrefs"
+verify_projected_sort "-s userrefs -S guid"
+verify_projected_sort "-s objsetid"
+verify_projected_sort "-S objsetid"
+log_must set_tunable32 SNAPSHOT_LIST_BATCH_SIZE 2
+verify_filtered_batch_fill
+verify_filtered_fallback
+log_must set_tunable32 SNAPSHOT_LIST_BATCH_SIZE 1
+run_injected_empty_batch
+run_injected_eof_after_first
+log_must set_tunable32 SNAPSHOT_LIST_BATCH_SIZE 1024
+log_must zfs clone "$DATASET@m_oldest" "$CLONE_DATASET"
+verify_projected_property_union
+log_must zfs destroy "$CLONE_DATASET"
+log_must rm -f "$EXPECTED_OUTPUT"
+verify_projected_redacted
+for error in cmd_unavail arg_unavail enotty enotsup enoent esrch; do
+ log_must snapshot_list_test callback-error "$DATASET" "$error"
+done
+for error in enoent esrch; do
+ log_must snapshot_list_test bookmark-callback-error "$DATASET" "$error"
+done
+run_injected_handle_enomem
+verify_direct_properties
+run_injected_metadata_errors
+log_must eval "zfs list -H -p -t snapshot -o '$COLUMNS' '$DATASET' " \
+ "> '$BATCH_OUTPUT'"
+run_injected_list enomem "$INJECTED_OUTPUT"
+run_injected_list real_enomem "$INJECTED_OUTPUT"
+run_injected_list cmd_unavail "$INJECTED_OUTPUT"
+run_injected_list enotty "$INJECTED_OUTPUT"
+run_injected_list enotsup "$INJECTED_OUTPUT"
+run_injected_list arg_unavail "$INJECTED_OUTPUT"
+verify_mixed_type_fallback
+
+log_must touch "$EXPECTED_OUTPUT"
+run_injected_list enoent "$INJECTED_OUTPUT" name "$EXPECTED_OUTPUT"
+run_injected_list esrch "$INJECTED_OUTPUT" name "$EXPECTED_OUTPUT"
+
+printf "%s\n" "$DATASET@m_oldest" "$DATASET@z_middle" \
+ "$DATASET@a_newest" > "$EXPECTED_OUTPUT"
+run_injected_list enomem "$INJECTED_OUTPUT" name "$EXPECTED_OUTPUT" \
+ "-s type"
+run_injected_list cmd_unavail "$INJECTED_OUTPUT" name "$EXPECTED_OUTPUT" \
+ "-s type"
+run_injected_list enotty "$INJECTED_OUTPUT" name "$EXPECTED_OUTPUT" \
+ "-s type"
+run_injected_list enotsup "$INJECTED_OUTPUT" name "$EXPECTED_OUTPUT" \
+ "-s type"
+run_injected_list arg_unavail "$INJECTED_OUTPUT" name "$EXPECTED_OUTPUT" \
+ "-s type"
+
+run_injected_interrupt
+log_must set_tunable32 SNAPSHOT_LIST_BATCH_SIZE 1
+run_injected_late_failure
+log_must set_tunable32 SNAPSHOT_LIST_BATCH_SIZE 1024
+
+log_pass "Projected listing preserves ordering through error paths."
diff --git a/tests/zfs-tests/tests/functional/cli_root/zfs_list/zfs_list_013_pos.ksh b/tests/zfs-tests/tests/functional/cli_root/zfs_list/zfs_list_013_pos.ksh
new file mode 100755
index 000000000000..267cba7ff554
--- /dev/null
+++ b/tests/zfs-tests/tests/functional/cli_root/zfs_list/zfs_list_013_pos.ksh
@@ -0,0 +1,358 @@
+#!/bin/ksh -p
+# SPDX-License-Identifier: CDDL-1.0
+#
+
+. $STF_SUITE/include/libtest.shlib
+
+#
+# DESCRIPTION:
+# Snapshot and bookmark listing remains consistent across dataset types,
+# object counts, names, properties, ordering, and concurrent mutation.
+#
+# STRATEGY:
+# 1. Compare listing output with the full-stat path for filesystems, clones,
+# volumes, snapshots, bookmarks, sort keys, and JSON output.
+# 2. Verify empty output and exact counts at 1023, 1024, and 1025 snapshots.
+# 3. Verify maximum-length snapshot and bookmark names.
+# 4. Verify default, tied, explicit-name, and mixed-type ordering.
+# 5. Compare all 255 nonempty subsets of eight common properties with full
+# stats.
+# 6. List snapshots while another process creates, renames, and destroys one.
+#
+
+verify_runnable "global"
+set -o pipefail
+
+DATASET="$TESTPOOL/$TESTFS/list_generic"
+CLONE="$TESTPOOL/$TESTFS/list_generic_clone"
+VOLUME="$TESTPOOL/$TESTFS/list_generic_vol"
+BOUNDARY_DATASET="$TESTPOOL/$TESTFS/list_generic_boundary"
+SUBSET_DATASET="$TESTPOOL/$TESTFS/list_generic_subsets"
+MAX_DATASET="$TESTPOOL/$TESTFS/list_generic_max_name"
+DATASET_MOUNT="$TESTDIR/list_generic"
+CLONE_MOUNT="$TESTDIR/list_generic_clone"
+OUTPUT="$TEST_BASE_DIR/list_generic_output.$$"
+LEGACY_OUTPUT="$TEST_BASE_DIR/list_generic_legacy.$$"
+EXPECTED_OUTPUT="$TEST_BASE_DIR/list_generic_expected.$$"
+RACE_LOG="$TEST_BASE_DIR/list_generic_race.$$"
+COLUMNS="createtxg,creation,guid,name,written,type,userrefs,objsetid"
+PROPERTIES="createtxg creation guid name written type userrefs objsetid"
+HOLD_TAG="list-generic"
+race_pid=""
+
+function cleanup
+{
+ [[ -n "$race_pid" ]] && kill "$race_pid" >/dev/null 2>&1
+ [[ -n "$race_pid" ]] && wait "$race_pid" >/dev/null 2>&1
+ zfs release "$HOLD_TAG" "$DATASET@after_append" >/dev/null 2>&1
+ zfs release "$HOLD_TAG" "$VOLUME@second" >/dev/null 2>&1
+ rm -f "$OUTPUT" "$LEGACY_OUTPUT" "$EXPECTED_OUTPUT" "$RACE_LOG"
+ datasetexists "$CLONE" && zfs destroy -r "$CLONE"
+ datasetexists "$VOLUME" && zfs destroy -r "$VOLUME"
+ datasetexists "$DATASET" && zfs destroy -r "$DATASET"
+ datasetexists "$MAX_DATASET" && zfs destroy -r "$MAX_DATASET"
+ datasetexists "$SUBSET_DATASET" && zfs destroy -r "$SUBSET_DATASET"
+ datasetexists "$BOUNDARY_DATASET" && zfs destroy -r "$BOUNDARY_DATASET"
+}
+
+function compare_full_stat
+{
+ typeset dataset="$1"
+ typeset object_types="$2"
+ typeset property
+ typeset projected_columns="used,available,referenced,refer,mountpoint"
+ projected_columns="$projected_columns,logicalused,lused"
+ projected_columns="$projected_columns,logicalreferenced,lrefer,defer_destroy"
+
+ for property in createtxg creation guid name written type userrefs \
+ objsetid; do
+ log_must eval "zfs list -H -p -t '$object_types' -d 1 " \
+ "-s '$property' -o '$COLUMNS' '$dataset' > '$OUTPUT'"
+ log_must eval "zfs list -H -p -t '$object_types' -d 1 " \
+ "-s '$property' -s test:force-legacy-iterator " \
+ "-o '$COLUMNS' '$dataset' > '$LEGACY_OUTPUT'"
+ log_must diff "$LEGACY_OUTPUT" "$OUTPUT"
+ done
+
+ log_must eval "zfs list -j -p -t '$object_types' -d 1 " \
+ "-o '$COLUMNS' '$dataset' > '$OUTPUT'"
+ log_must eval "zfs list -j -p -t '$object_types' -d 1 " \
+ "-s test:force-legacy-iterator " \
+ "-o '$COLUMNS' '$dataset' > '$LEGACY_OUTPUT'"
+ log_must diff "$LEGACY_OUTPUT" "$OUTPUT"
+
+ log_must eval "zfs list -t '$object_types' -d 1 -o '$COLUMNS' " \
+ "'$dataset' > '$OUTPUT'"
+ log_must eval "zfs list -t '$object_types' -d 1 " \
+ "-s test:force-legacy-iterator " \
+ "-o '$COLUMNS' '$dataset' > '$LEGACY_OUTPUT'"
+ log_must diff "$LEGACY_OUTPUT" "$OUTPUT"
+
+ log_must eval "zfs list -H -p -t '$object_types' -d 1 " \
+ "-o '$projected_columns' '$dataset' > '$OUTPUT'"
+ log_must eval "zfs list -H -p -t '$object_types' -d 1 " \
+ "-s test:force-legacy-iterator -o '$projected_columns' '$dataset' " \
+ "> '$LEGACY_OUTPUT'"
+ log_must diff "$LEGACY_OUTPUT" "$OUTPUT"
+}
+
+function compare_written
+{
+ typeset dataset="$1"
+ typeset sort_options
+
+ for sort_options in "" "-s written" "-S written"; do
+ log_must eval "zfs list -H -p -t snapshot -o name,written " \
+ "$sort_options '$dataset' > '$OUTPUT'"
+ log_must eval "zfs list -H -p -t snapshot " \
+ "-o name,written $sort_options " \
+ "-s test:force-legacy-iterator '$dataset' " \
+ "> '$LEGACY_OUTPUT'"
+ log_must diff "$LEGACY_OUTPUT" "$OUTPUT"
+ done
+}
+
+function compare_json_name
+{
+ typeset dataset="$1"
+ typeset object_types="$2"
+
+ log_must eval "zfs list -j -p -t '$object_types' -d 1 -o name " \
+ "'$dataset' > '$OUTPUT'"
+ log_must eval "zfs list -j -p -t '$object_types' -d 1 " \
+ "-s test:force-legacy-iterator " \
+ "-o name '$dataset' > '$LEGACY_OUTPUT'"
+ log_must diff "$LEGACY_OUTPUT" "$OUTPUT"
+
+ log_must eval "zfs list -j --json-int -t '$object_types' -d 1 " \
+ "-o name '$dataset' > '$OUTPUT'"
+ log_must eval "zfs list -j --json-int -t '$object_types' -d 1 " \
+ "-s test:force-legacy-iterator -o name '$dataset' " \
+ "> '$LEGACY_OUTPUT'"
+ log_must diff "$LEGACY_OUTPUT" "$OUTPUT"
+}
+
+function verify_count
+{
+ typeset expected="$1"
+ typeset actual
+
+ actual=$(zfs list -H -t snapshot -o name "$BOUNDARY_DATASET" | wc -l) ||
+ log_fail "failed to list snapshots for $BOUNDARY_DATASET"
+ (( actual == expected )) ||
+ log_fail "expected $expected snapshots, found $actual"
+}
+
+function compare_order
+{
+ typeset types="$1"
+ typeset sort_options="$2"
+
+ log_must eval "zfs list -H -p -t '$types' -o name $sort_options " \
+ "'$SUBSET_DATASET' > '$OUTPUT'"
+ log_must diff "$EXPECTED_OUTPUT" "$OUTPUT"
+ log_must eval "zfs list -H -p -t '$types' -o name $sort_options " \
+ "-s test:force-legacy-iterator '$SUBSET_DATASET' " \
+ "> '$LEGACY_OUTPUT'"
+ log_must diff "$EXPECTED_OUTPUT" "$LEGACY_OUTPUT"
+}
+
+function compare_subset
+{
+ typeset columns="$1"
+
+ log_must eval "zfs list -H -p -t snapshot,bookmark -d 1 " \
+ "-o '$columns' '$SUBSET_DATASET' > '$OUTPUT'"
+ log_must eval "zfs list -H -p -t snapshot,bookmark -d 1 " \
+ "-s test:force-legacy-iterator -o '$columns' '$SUBSET_DATASET' " \
+ "> '$LEGACY_OUTPUT'"
+ log_must diff "$LEGACY_OUTPUT" "$OUTPUT"
+}
+
+log_onexit cleanup
+log_assert "Snapshot and bookmark listing handles generic listing cases."
+
+log_must zfs create "$DATASET"
+log_must file_write -o create -f "$DATASET_MOUNT/payload" \
+ -b 131072 -c 8 -d R
+log_must zfs snapshot "$DATASET@base"
+log_must file_write -o append -f "$DATASET_MOUNT/payload" \
+ -b 131072 -c 4 -d R
+log_must zfs snapshot "$DATASET@after_append"
+log_must rm "$DATASET_MOUNT/payload"
+log_must zfs snapshot "$DATASET@after_remove"
+log_must file_write -o create -f "$DATASET_MOUNT/merge_payload" \
+ -b 131072 -c 4 -d R
+log_must zfs snapshot "$DATASET@written_before"
+log_must file_write -o append -f "$DATASET_MOUNT/merge_payload" \
+ -b 131072 -c 4 -d R
+log_must zfs snapshot "$DATASET@written_middle"
+log_must file_write -o append -f "$DATASET_MOUNT/merge_payload" \
+ -b 131072 -c 4 -d R
+log_must zfs snapshot "$DATASET@written_after"
+compare_written "$DATASET"
+log_must zfs destroy "$DATASET@written_middle"
+snapexists "$DATASET@written_middle" && \
+ log_fail "middle snapshot still exists after destruction"
+compare_written "$DATASET"
+log_must zfs hold "$HOLD_TAG" "$DATASET@after_append"
+log_must zfs bookmark "$DATASET@base" "$DATASET#base"
+log_must zfs bookmark "$DATASET@after_append" "$DATASET#after_append"
+log_must zfs snapshot "$DATASET@bookmark_source"
+log_must zfs bookmark "$DATASET@bookmark_source" \
+ "$DATASET#source_destroyed"
+log_must zfs destroy "$DATASET@bookmark_source"
+snapexists "$DATASET@bookmark_source" &&
+ log_fail "bookmark source snapshot still exists"
+
+log_must zfs clone -o mountpoint="$CLONE_MOUNT" "$DATASET@base" "$CLONE"
+log_must file_write -o create -f "$CLONE_MOUNT/payload" \
+ -b 131072 -c 4 -d R
+log_must zfs snapshot "$CLONE@first"
+log_must rm "$CLONE_MOUNT/payload"
+log_must zfs snapshot "$CLONE@after_remove"
+log_must zfs bookmark "$CLONE@first" "$CLONE#first"
+
+log_must zfs create -V 64M "$VOLUME"
+log_must zfs snapshot "$VOLUME@first"
+log_must zfs snapshot "$VOLUME@second"
+log_must zfs hold "$HOLD_TAG" "$VOLUME@second"
+log_must zfs bookmark "$VOLUME@first" "$VOLUME#first"
+
+compare_full_stat "$DATASET" snapshot
+compare_full_stat "$DATASET" snapshot,bookmark
+compare_full_stat "$CLONE" snapshot
+compare_full_stat "$CLONE" snapshot,bookmark
+compare_full_stat "$VOLUME" snapshot
+compare_full_stat "$VOLUME" snapshot,bookmark
+compare_full_stat "$DATASET" bookmark
+compare_json_name "$DATASET" bookmark
+compare_json_name "$DATASET" snapshot,bookmark
+
+log_must zfs create "$BOUNDARY_DATASET"
+log_must eval "zfs list -H -p -t snapshot,bookmark -d 1 -o name " \
+ "'$BOUNDARY_DATASET' > '$OUTPUT'"
+[[ ! -s "$OUTPUT" ]] || log_fail "empty dataset produced objects"
+
+typeset -i index=0
+while (( index < 1023 )); do
+ suffix=$(printf "%04d" "$index")
+ log_must zfs snapshot "$BOUNDARY_DATASET@suffix_$suffix"
+ (( index += 1 ))
+done
+verify_count 1023
+log_must zfs snapshot "$BOUNDARY_DATASET@suffix_1023"
+verify_count 1024
+log_must zfs snapshot "$BOUNDARY_DATASET@suffix_1024"
+verify_count 1025
+
+log_must eval "zfs list -H -p -t snapshot -o '$COLUMNS' " \
+ "'$BOUNDARY_DATASET' > '$OUTPUT'"
+log_must eval "zfs list -H -p -t snapshot " \
+ "-s test:force-legacy-iterator -o '$COLUMNS' '$BOUNDARY_DATASET' " \
+ "> '$LEGACY_OUTPUT'"
+log_must diff "$LEGACY_OUTPUT" "$OUTPUT"
+
+log_must zfs create "$MAX_DATASET"
+typeset -i max_component_length
+(( max_component_length = 255 - ${#MAX_DATASET} - 1 ))
+max_name=$(printf "%${max_component_length}s" "" | tr ' ' s)
+too_long_name="${max_name}s"
+full_name="$MAX_DATASET@$max_name"
+(( ${#full_name} == 255 )) ||
+ log_fail "failed to construct a 255-byte snapshot name"
+log_must zfs snapshot "$full_name"
+log_must zfs bookmark "$full_name" "$MAX_DATASET#$max_name"
+log_mustnot zfs snapshot "$MAX_DATASET@$too_long_name"
+log_must eval "zfs list -H -t snapshot,bookmark -d 1 -o name " \
+ "'$MAX_DATASET' > '$OUTPUT'"
+log_must grep -Fx "$full_name" "$OUTPUT"
+log_must grep -Fx "$MAX_DATASET#$max_name" "$OUTPUT"
+
+log_must zfs create "$SUBSET_DATASET"
+log_must zfs snapshot "$SUBSET_DATASET@m_oldest"
+log_must sleep 1.1
+log_must zfs snapshot "$SUBSET_DATASET@z_middle"
+log_must sleep 1.1
+log_must zfs snapshot "$SUBSET_DATASET@a_newest"
+oldest_txg=$(zfs get -H -p -o value createtxg \
+ "$SUBSET_DATASET@m_oldest")
+middle_txg=$(zfs get -H -p -o value createtxg \
+ "$SUBSET_DATASET@z_middle")
+newest_txg=$(zfs get -H -p -o value createtxg \
+ "$SUBSET_DATASET@a_newest")
+(( oldest_txg < middle_txg && middle_txg < newest_txg )) ||
+ log_fail "ordering snapshots do not have increasing creation TXGs"
+
+log_must zfs bookmark "$SUBSET_DATASET@m_oldest" \
+ "$SUBSET_DATASET#m_oldest"
+log_must zfs bookmark "$SUBSET_DATASET@z_middle" \
+ "$SUBSET_DATASET#z_middle"
+log_must zfs bookmark "$SUBSET_DATASET@a_newest" \
+ "$SUBSET_DATASET#a_newest"
+
+printf "%s\n" "$SUBSET_DATASET@m_oldest" "$SUBSET_DATASET@z_middle" \
+ "$SUBSET_DATASET@a_newest" > "$EXPECTED_OUTPUT"
+compare_order snapshot ""
+compare_order snapshot "-s creation"
+compare_order snapshot "-s type"
+compare_order snapshot "-S type"
+
+printf "%s\n" "$SUBSET_DATASET@a_newest" "$SUBSET_DATASET@z_middle" \
+ "$SUBSET_DATASET@m_oldest" > "$EXPECTED_OUTPUT"
+compare_order snapshot "-S creation"
+
+printf "%s\n" "$SUBSET_DATASET@a_newest" "$SUBSET_DATASET@m_oldest" \
+ "$SUBSET_DATASET@z_middle" > "$EXPECTED_OUTPUT"
+compare_order snapshot "-s name"
+
+printf "%s\n" "$SUBSET_DATASET@z_middle" "$SUBSET_DATASET@m_oldest" \
+ "$SUBSET_DATASET@a_newest" > "$EXPECTED_OUTPUT"
+compare_order snapshot "-S name"
+
+printf "%s\n" "$SUBSET_DATASET@m_oldest" "$SUBSET_DATASET@z_middle" \
+ "$SUBSET_DATASET@a_newest" "$SUBSET_DATASET#a_newest" \
+ "$SUBSET_DATASET#m_oldest" "$SUBSET_DATASET#z_middle" \
+ > "$EXPECTED_OUTPUT"
+compare_order snapshot,bookmark "-d 1"
+
+set -A property_array $PROPERTIES
+typeset -i mask property_index
+typeset columns separator
+for (( mask = 1; mask < 256; mask += 1 )); do
+ columns=""
+ separator=""
+ for (( property_index = 0; property_index < 8; property_index += 1 )); do
+ if (( mask & (1 << property_index) )); then
+ columns="${columns}${separator}${property_array[property_index]}"
+ separator=","
+ fi
+ done
+ compare_subset "$columns"
+done
+
+log_must zfs destroy "$BOUNDARY_DATASET@suffix_1024"
+verify_count 1024
+(
+ typeset -i race_index=0
+ while (( race_index < 64 )); do
+ zfs snapshot "$BOUNDARY_DATASET@zz_race" || exit 1
+ zfs rename "$BOUNDARY_DATASET@zz_race" \
+ "$BOUNDARY_DATASET@zz_renamed" || exit 1
+ zfs destroy "$BOUNDARY_DATASET@zz_renamed" || exit 1
+ (( race_index += 1 ))
+ done
+) >"$RACE_LOG" 2>&1 &
+race_pid=$!
+
+for (( index = 0; index < 64; index += 1 )); do
+ log_must eval "zfs list -H -p -t snapshot -o '$COLUMNS' " \
+ "'$BOUNDARY_DATASET' > /dev/null"
+done
+if ! wait "$race_pid"; then
+ cat "$RACE_LOG"
+ log_fail "concurrent snapshot mutation failed"
+fi
+race_pid=""
+
+log_pass "Snapshot and bookmark listing handles generic listing cases."
diff --git a/tests/zfs-tests/tests/functional/cli_root/zfs_list/zfs_list_014_pos.ksh b/tests/zfs-tests/tests/functional/cli_root/zfs_list/zfs_list_014_pos.ksh
new file mode 100755
index 000000000000..c85dd0468d11
--- /dev/null
+++ b/tests/zfs-tests/tests/functional/cli_root/zfs_list/zfs_list_014_pos.ksh
@@ -0,0 +1,49 @@
+#!/bin/ksh -p
+# SPDX-License-Identifier: CDDL-1.0
+#
+
+. $STF_SUITE/include/libtest.shlib
+
+#
+# DESCRIPTION:
+# Dependent iteration with batching includes snapshot clones.
+#
+# STRATEGY:
+# 1. Create three snapshots and clone one into a sibling dataset.
+# 2. Verify batched dependent iteration reports every snapshot and the clone.
+# 3. Verify simple batched iteration reports the same dependent datasets.
+#
+
+verify_runnable "global"
+set -o pipefail
+
+DATASET="$TESTPOOL/$TESTFS/list_dependents"
+CLONE="$TESTPOOL/$TESTFS/list_dependents_clone"
+OUTPUT="$TEST_BASE_DIR/list_dependents_output.$$"
+EXPECTED_OUTPUT="$TEST_BASE_DIR/list_dependents_expected.$$"
+
+function cleanup
+{
+ rm -f "$OUTPUT" "$EXPECTED_OUTPUT"
+ datasetexists "$CLONE" && zfs destroy -r "$CLONE"
+ datasetexists "$DATASET" && zfs destroy -r "$DATASET"
+}
+
+log_onexit cleanup
+log_assert "Dependent iteration with batching discovers snapshot clones."
+
+log_must zfs create "$DATASET"
+log_must zfs snapshot "$DATASET@m_oldest"
+log_must zfs snapshot "$DATASET@z_middle"
+log_must zfs snapshot "$DATASET@a_newest"
+log_must zfs clone -o mountpoint=none "$DATASET@m_oldest" "$CLONE"
+
+printf "%s\n" "$DATASET@m_oldest" "$DATASET@z_middle" \
+ "$DATASET@a_newest" "$CLONE" | sort > "$EXPECTED_OUTPUT"
+log_must eval "snapshot_list_test dependents '$DATASET' | sort > '$OUTPUT'"
+log_must diff "$EXPECTED_OUTPUT" "$OUTPUT"
+log_must eval "snapshot_list_test dependents-simple '$DATASET' | sort " \
+ "> '$OUTPUT'"
+log_must diff "$EXPECTED_OUTPUT" "$OUTPUT"
+
+log_pass "Dependent iteration with batching discovers snapshot clones."
diff --git a/tests/zfs-tests/tests/functional/cli_root/zfs_list/zfs_list_016_pos.ksh b/tests/zfs-tests/tests/functional/cli_root/zfs_list/zfs_list_016_pos.ksh
new file mode 100755
index 000000000000..1ae5eb693594
--- /dev/null
+++ b/tests/zfs-tests/tests/functional/cli_root/zfs_list/zfs_list_016_pos.ksh
@@ -0,0 +1,55 @@
+#!/bin/ksh -p
+# SPDX-License-Identifier: CDDL-1.0
+#
+
+. $STF_SUITE/include/libtest.shlib
+
+#
+# DESCRIPTION:
+# Projected written-property lookup preserves legacy error handling by failing
+# on a current snapshot deadlist error while omitting written when only the
+# predecessor cannot be read. TXG-filtered snapshots still access the current
+# deadlist but avoid accessing the predecessor.
+#
+# STRATEGY:
+# 1. Create and export a real file-backed pool with two snapshots.
+# 2. Open it through libzpool with a helper that injects EIO for one exact MOS
+# object at a time through a link-time wrapper.
+# 3. Verify current-deadlist and predecessor behavior for unfiltered snapshots
+# and snapshots excluded by either TXG bound.
+#
+
+verify_runnable "global"
+
+typeset ERROR_POOL="${TESTPOOL}_written_errors_$$"
+typeset ERROR_DATASET="$ERROR_POOL/fs"
+typeset ERROR_VDEV="$TEST_BASE_DIR/zfs_list_written_errors_vdev.$$"
+
+function cleanup
+{
+ if ! poolexists "$ERROR_POOL"; then
+ zpool import -d "$TEST_BASE_DIR" "$ERROR_POOL" >/dev/null 2>&1
+ fi
+ poolexists "$ERROR_POOL" && destroy_pool "$ERROR_POOL"
+ rm -f "$ERROR_VDEV"
+}
+
+log_onexit cleanup
+log_assert "Projected written lookup distinguishes deadlist errors."
+
+log_must truncate -s "$MINVDEVSIZE" "$ERROR_VDEV"
+log_must zpool create -f -o cachefile=none -O mountpoint=none \
+ "$ERROR_POOL" "$ERROR_VDEV"
+log_must zfs create -o mountpoint=none "$ERROR_DATASET"
+log_must zfs snapshot "$ERROR_DATASET@first"
+log_must zfs snapshot "$ERROR_DATASET@second"
+log_must zpool sync "$ERROR_POOL"
+log_must zpool export "$ERROR_POOL"
+
+log_must snapshot_list_stats_test "$ERROR_DATASET@second" "$TEST_BASE_DIR"
+
+log_must zpool import -d "$TEST_BASE_DIR" "$ERROR_POOL"
+log_must destroy_pool "$ERROR_POOL"
+log_must rm -f "$ERROR_VDEV"
+
+log_pass "Projected written lookup distinguishes deadlist errors."
diff --git a/tests/zfs-tests/tests/functional/cli_root/zfs_list/zfs_list_017_pos.ksh b/tests/zfs-tests/tests/functional/cli_root/zfs_list/zfs_list_017_pos.ksh
new file mode 100755
index 000000000000..f6664ff9a138
--- /dev/null
+++ b/tests/zfs-tests/tests/functional/cli_root/zfs_list/zfs_list_017_pos.ksh
@@ -0,0 +1,150 @@
+#!/bin/ksh -p
+# SPDX-License-Identifier: CDDL-1.0
+#
+
+. $STF_SUITE/include/libtest.shlib
+
+#
+# DESCRIPTION:
+# Batched snapshot iteration delivers, from the failing ioctl, results
+# collected before a metadata EIO and matches legacy iteration.
+#
+# STRATEGY:
+# 1. Create three snapshots in an isolated file-backed pool.
+# 2. Inject EIO into the user-reference ZAP of a late snapshot.
+# 3. Verify the failing batched ioctl itself contains snapshot results.
+# 4. Verify legacy and batched iteration return the same callbacks before EIO.
+#
+
+verify_runnable "global"
+set -o pipefail
+
+typeset ERROR_POOL="${TESTPOOL}_partial_eio_$$"
+typeset ERROR_DATASET="$ERROR_POOL/fs"
+typeset ERROR_VDEV="$TEST_BASE_DIR/zfs_list_partial_eio_vdev.$$"
+typeset ORDER="$TEST_BASE_DIR/zfs_list_partial_eio_order.$$"
+typeset MARKER="$TEST_BASE_DIR/zfs_list_partial_eio_marker.$$"
+typeset HANDLER=
+typeset PRELOAD
+typeset USERREFS_OBJ
+typeset USERREFS_HEX
+typeset TARGET
+typeset -i EXPECTED_CALLBACKS
+
+function cleanup
+{
+ [[ -n "$HANDLER" ]] && zinject -c "$HANDLER" >/dev/null 2>&1
+ [[ -e "$TEST_BASE_DIR/tunable-SPA_LOAD_VERIFY_METADATA" ]] &&
+ restore_tunable SPA_LOAD_VERIFY_METADATA >/dev/null 2>&1
+ [[ -e "$TEST_BASE_DIR/tunable-SNAPSHOT_LIST_BATCH_TIME_US" ]] &&
+ restore_tunable SNAPSHOT_LIST_BATCH_TIME_US >/dev/null 2>&1
+ [[ -e "$TEST_BASE_DIR/tunable-SNAPSHOT_LIST_BATCH_SIZE" ]] &&
+ restore_tunable SNAPSHOT_LIST_BATCH_SIZE >/dev/null 2>&1
+ if ! poolexists "$ERROR_POOL"; then
+ zpool import -d "$TEST_BASE_DIR" "$ERROR_POOL" >/dev/null 2>&1
+ fi
+ poolexists "$ERROR_POOL" && destroy_pool "$ERROR_POOL"
+ rm -f "$ERROR_VDEV" "$ORDER" "$MARKER"
+}
+
+function find_shim
+{
+ typeset helper helper_dir candidate
+
+ helper=$(readlink -f "$(command -v snapshot_list_test)")
+ helper_dir=${helper%/*}
+ for candidate in \
+ "$helper_dir/.libs/libsnapshot_list_test_shim.so" \
+ "$helper_dir/libsnapshot_list_test_shim.so" \
+ "$STF_SUITE/bin/libsnapshot_list_test_shim.so"; do
+ [[ -f "$candidate" ]] && print -- "$candidate" && return 0
+ done
+ return 1
+}
+
+function verify_partial_error
+{
+ typeset mode="$1"
+ typeset injected
+
+ HANDLER=$(zinject -q -a -u -b "0:$USERREFS_HEX:0:0" \
+ "$ERROR_POOL") || log_fail "cannot inject $mode iterator EIO"
+ [[ "$HANDLER" == +([0-9]) ]] ||
+ log_fail "invalid zinject handler: $HANDLER"
+
+ if [[ "$mode" == "batched" ]]; then
+ log_must rm -f "$MARKER"
+ log_must env LD_PRELOAD="$PRELOAD" \
+ ZFS_SNAPSHOT_LIST_TEST_MODE=partial_eio_output \
+ ZFS_SNAPSHOT_LIST_TEST_MARKER="$MARKER" \
+ snapshot_list_test partial-error "$ERROR_DATASET" "$mode" \
+ "$EXPECTED_CALLBACKS"
+ log_must grep -Fxq partial_eio_output "$MARKER"
+ else
+ log_must snapshot_list_test partial-error "$ERROR_DATASET" \
+ "$mode" "$EXPECTED_CALLBACKS"
+ fi
+ injected=$(zinject | awk -v id="$HANDLER" \
+ '$1 == id { print $NF }')
+ [[ -n "$injected" ]] && ((injected > 0)) ||
+ log_fail "$mode iterator did not trigger EIO"
+
+ log_must zinject -c "$HANDLER"
+ HANDLER=
+}
+
+log_onexit cleanup
+log_assert "Batched iteration returns snapshots collected before EIO."
+
+SHIM=$(find_shim) || log_unsupported "snapshot-list test shim not found"
+PRELOAD="$SHIM"
+[[ -n "$LD_PRELOAD" ]] && PRELOAD="$SHIM:$LD_PRELOAD"
+
+log_must save_tunable SPA_LOAD_VERIFY_METADATA
+log_must save_tunable SNAPSHOT_LIST_BATCH_SIZE
+log_must save_tunable SNAPSHOT_LIST_BATCH_TIME_US
+log_must set_tunable32 SNAPSHOT_LIST_BATCH_SIZE 1024
+log_must set_tunable32 SNAPSHOT_LIST_BATCH_TIME_US 100000
+log_must truncate -s "$MINVDEVSIZE" "$ERROR_VDEV"
+log_must zpool create -f -o cachefile=none -O mountpoint=none \
+ "$ERROR_POOL" "$ERROR_VDEV"
+log_must zfs create -o mountpoint=none "$ERROR_DATASET"
+log_must zfs snapshot "$ERROR_DATASET@first"
+log_must zfs snapshot "$ERROR_DATASET@second"
+log_must zfs snapshot "$ERROR_DATASET@latest"
+log_must eval "snapshot_list_test filter '$ERROR_DATASET' 0 0 > '$ORDER'"
+
+TARGET=$(awk -v latest="$ERROR_DATASET@latest" \
+ '$0 != latest { target=$0 } END { print target }' "$ORDER")
+EXPECTED_CALLBACKS=$(awk -v target="$TARGET" \
+ '$0 == target { print NR - 1; exit }' "$ORDER")
+[[ -n "$TARGET" ]] && ((EXPECTED_CALLBACKS > 0)) ||
+ log_fail "cannot select a late non-latest snapshot"
+
+typeset -i i=0
+while ((i < 40)); do
+ log_must zfs hold "partial-eio-$i-abcdefghijklmnopqrstuvwxyz" "$TARGET"
+ ((i += 1))
+done
+log_must zpool sync "$ERROR_POOL"
+
+typeset dsobj=$(zfs get -H -o value objsetid "$TARGET")
+log_must zpool export "$ERROR_POOL"
+USERREFS_OBJ=$(zdb -e -p "$TEST_BASE_DIR" -dddd "$ERROR_POOL" \
+ "$dsobj" 2>/dev/null | awk '$1 == "userrefs_obj" { print $3 }')
+[[ -n "$USERREFS_OBJ" ]] && ((USERREFS_OBJ > 0)) ||
+ log_fail "cannot find user-reference ZAP for $TARGET"
+USERREFS_HEX=$(printf "%x" "$USERREFS_OBJ")
+log_must zpool import -d "$TEST_BASE_DIR" "$ERROR_POOL"
+
+log_must set_tunable32 SPA_LOAD_VERIFY_METADATA 0
+verify_partial_error legacy
+verify_partial_error batched
+log_must restore_tunable SPA_LOAD_VERIFY_METADATA
+log_must restore_tunable SNAPSHOT_LIST_BATCH_TIME_US
+log_must restore_tunable SNAPSHOT_LIST_BATCH_SIZE
+
+log_must destroy_pool "$ERROR_POOL"
+log_must rm -f "$ERROR_VDEV" "$ORDER" "$MARKER"
+
+log_pass "Batched iteration returns snapshots collected before EIO."