Files
RetroArch/samples/playlist/playlist_parity_test.c
T
Davey HughesandLibretroAdmin 08e5b72a88 Scan from the command line: exit status, Manual Scan options, playlist refresh (#19977)
* file_path: stop at the end of the path after '~'

fill_pathname_expand_special() skipped two characters after a leading
'~' or ':', so a bare "~" read past its NUL and "~x" lost the x. It
now skips the prefix and a slash after it, if there is one.

* scan: hand the callback user data and what the scan did

task_push_dbscan() and task_push_manual_content_scan() take user data
for their callback, and the callback's task_data is now a
task_dbscan_result_t: how many entries the scan added, how many its
playlists already held, and how many files matched nothing. A file is
counted by what became of it, so an archive that is expanded is not
counted as unmatched, and a loose scan's unmatched files count as
added. Callers pass NULL for now.

* scan: say in the result when a scan failed

A scan that ends before it runs (bad setup, no valid content, a DAT
that will not load) or ends with a task error now sets the result's
failed flag. A database folder that is missing or unset is treated as
an empty one, so it reports the same "no database" error instead of
finishing as if it had scanned.

* scan: count the files a scan could not read

A file whose scan fails (an unreadable file, a failed lookup) was in
no count. The result now carries an errors count for them. They do
not fail the scan.

* scan: print --scan errors on stderr and totals on stdout

A command-line scan now ends with a totals line on stdout, or with the
reason it failed on stderr. A file it could not read is listed on
stderr. The ++/==/?? marks are coloured only when their stream is a
terminal, so pipes and redirected output get plain text; on Windows
redirected output keeps its marks instead of losing them to
WriteConsole.

* scan: the manual scan finder skips a finished task

A finished scan stays in the task queue while its callback runs, and
the finder that refuses a second scan for the same playlist still
matched it, so a scan pushed from that callback was refused.

* retroarch: run every --scan, then exit with what the scans did

Each --scan is collected while the command line is parsed and run in
turn once it is done, so later options and further --scan flags are
no longer dropped. A path is expanded and made absolute first; one
that does not exist, or a scan that cannot start, is reported on
stderr and the next path still runs. --scan with --cloudsync or
--command is refused.

Without --menu the exit status is 1 if any scan failed, else 0 if any
added an entry, else 2, and a content path given with the scans is
ignored, with a line on stderr. With --menu each finished scan starts
the next, as a second scan is refused while one runs.

* manual_content_scan: clear the database name a refresh did not save

A playlist with no saved database name left the name buffer
uninitialised, and the refresh compared it against every installed
database, so whatever was on the stack could pick a database the
playlist never named.

* scan: Skip Database References names the scan's one playlist

The option was meant to put the playlist's name in each entry's
db_name, even for a database match (#18641), for users who build
their own thumbnail sets. When the scan picks the playlists, db_name
also picks the playlist an entry goes to, so with the option on every
match went to one playlist named after the content folder.

It now applies only when the scan writes one playlist, and gives
that playlist's name, a loose scan's unmatched entries included. With
the target on <Automatic>, or Fully Automatic with the option left on
from a Custom scan, matches go to their databases' playlists as with
it off; the entries of a custom playlist look their thumbnails up
under its name, not the folder's.

* scan: name the scanned folder in the start line

The "Scanning content" line, in the log and in --scan's output, has
named the database folder since the manual scan and the database scan
were merged (2fdf736d51); before, it named the path being scanned. It
names the content folder again.

* manual_content_scan: reset, scan overrides and their apply

The command line's scan options need each scan to start from the
defaults and to lay their values over them, or over a playlist's saved
settings. Add a reset, a record of those options and a function that
applies it through the existing setters: a core named by path, file
name or display name, a database, a playlist, the database check or a
DAT, extensions and the switches, stopping with a reason at the first
that does not resolve.

A core path is normalised before it is compared. A core's file name
matches in the case given first, then ignoring case, on every
platform; a display name matches exactly. Given another database, a
playlist named after its database keeps that name as a custom one, so
a refresh still writes the same playlist.

The core setter's copy step becomes its own function, so a core found
by path or file name can be set. Nothing calls the new functions yet.

* scan: run --scan items through one chain

The --scan paths ran through a loop in retroarch.c, and with --menu
through a second chain of their own. The scan options and playlist
refreshes to come need one runner for every item, from the command
line and from the menu.

task_push_dbscan_chain() runs a list of items one task at a time: a
prep task lists the databases, then each step is pushed from the last
task's callback on the main thread, so nothing waits and nothing
locks. An item that fails is reported and the run goes on; a
cancelled task ends it. retroarch.c builds the items in the first
argument pass and hands them to the chain; without --menu it waits
for the chain's end and exits as before.

Running out of memory prints "out of memory" on stderr: for an item's
path the item fails, and for the chain itself nothing runs, which
without --menu exits 1.

The chain also prints a scan's override errors, and the warnings for
an option that needs one playlist or does nothing for a single file,
which the scan options of the next commit can trigger.

* retroarch: Manual Scan options for --scan

--scan-playlist, --scan-database, --scan-match, --scan-dat, --scan-core,
--scan-exts and the five switches set the Manual Scan settings of the
--scan before them; a --scan without any stays Fully Automatic.

Values are checked in the first pass, so an option before any --scan,
an empty or bad value, or one too long for its setting ends the run
with its reason on stderr and status 1, before the config loads and
before anything is scanned. Cores, databases and DAT files are
resolved when their scan runs, and one that does not resolve fails
only that scan.

* scan: refresh playlists in the chain

A refresh item rescans one playlist with its saved scan settings, as
the menu's Refresh Playlist does, with the item's scan options laid
over them and written back by the scan. A refresh-all item does the
same for every playlist in the playlist folder that has saved
settings, in the order Manage Playlists lists them, histories and
favorites left out.

A refresh item names its playlist by name or by a path in the playlist
folder. A bare "name.lpl" is the playlist folder's file, and an
absolute path is normalised before it is checked against the folder.
The name is looked up in the folder's listing on every platform, the
exact name first, then ignoring case, since macOS and FAT/exFAT cards
ignore case; the refresh then takes the file's own name, so a
database's playlist stays one. Two or more playlists that match only
in other cases fail the item.

The playlist folder walk and each playlist load run in prep tasks: on
the worker with a threaded queue, under the shared I/O window without
one, each pushed from the previous step's callback. A playlist whose
saved content folder is gone is skipped rather than failed. A listing
that runs out of memory fails the refresh-all; a playlist folder that
is missing or cannot be read lists nothing, and the refresh-all ends
with "no playlists with saved scan settings". A refresh's start line
names its content folder; its totals and failure lines name the
playlist.

* retroarch: --scan-refresh and --scan-refresh-all

--scan-refresh=NAME|PATH rescans a playlist with its saved scan
settings, as the menu's Refresh Playlist does, and --scan-refresh-all
rescans every playlist that has them. Both are items of the same run
as --scan: the scan options after one are laid over the saved
settings and written back, and the exit status follows the same rule.

--scan-playlist after a refresh is refused, so a refresh always
updates its own playlist, and an empty --scan-refresh is refused as
the other empty values are. Like --scan, the refresh options cannot be
combined with --cloudsync or --command; the error names whichever of
the three comes first on the command line.

* menu: Refresh All Playlists

Manage Playlists gets Refresh All Playlists as its first entry when it
lists any playlist, so an empty list still says there are no entries.
It rescans every playlist that has saved scan settings, with those
settings, through the same chain as --scan-refresh-all, and ends with
one notification saying how many playlists were refreshed, skipped
(content folder gone) and failed.

While a content scan runs it is refused with a new message; a refused
scan had none. Other languages come through Crowdin.

While a scan chain runs (Refresh All, or a command-line scan with
--menu), Start Scan, a playlist's Refresh, Scan Directory and Scan
File are refused with the same message, as they would run with the
settings the chain last loaded. task_push_dbscan() returns false then
too, which covers the companion and the iOS caller.

* docs: --scan, refresh and the Manual Scan options in the man page

--help lists the options but not what a script needs: the order the
scans run in, the exit status and what the options set. The man page
now has them, next to --cloudsync, and CHANGES.md has the entry.

* scan chain: writes from workers reach the playlist cache by note

A playlist written by a task - the chain's prep parse, a scan, a
playlist-manager task, whatever thread it runs on - leaves a note on a
lock-free stack instead of reading the cached playlist; the main
thread applies the notes when it next decides whether the cache may
stand in, so no worker reads playlist_cached while the main thread
frees or installs it. Without threads or pointer atomics a write
applies at once.

The chain's prep tasks set their handlers by name, and
thread_read_audit.py fails on a handler set from a
retro_task_handler_t parameter, which would hide every handler passed
in. The chain puts the Manual Scan settings it found back when it
ends, and is no longer running when its done callback runs, so that
callback may start another. task_push_dbscan() says why it refuses,
so the menu and the companions show the same reason, and the
companions' status line takes it from companion_core. --scan and
--scan-refresh set the verbosity override, so a config with logging
off cannot detach the console the totals go to, and refuse a path too
long for the chain's buffers. The Refresh All notification passes the
length written, and fill_pathname_expand_special() stops at the end of
a buffer shorter than the home or application directory.

Lanes: playlist_parity_test writes the cached file from a worker,
once at the same size and then while the main thread reuses and frees
the cache; database_scan_test checks the refusal message, the restored
settings and a done callback that starts the next chain;
fill_pathname_test expands into a buffer shorter than the home
directory.

* playlist: header-only parse for the scan chain's prep

The prep task needs a playlist's saved scan settings and nothing else,
yet parsed every entry and, through autofix, could write the file back
from a worker. playlist_parse_begin_header() stops at "items", rebases
the scan record in memory when the base content directory moved, and
marks the playlist so playlist_write_file() refuses it.

Lane: playlist_parity_test header_only (entries skipped, scan dir
rebased, file bytes untouched, old format).

* scan chain: ask the file system nothing on the main thread

The chain's steps ran on the main thread between its tasks and stat'd
and realpath'd there: a --scan path's resolve and stat, a
--scan-refresh path's two realpaths, the content folder's stat, the
saved and override DATs' stat and size, an override core path's
realpath.  On a network share each of those can stall a frame.

- manual_content_scan_fs_read() reads them on any thread, touching
  nothing but its arguments; the _fs variants of the content-folder,
  playlist and override setters take its answers instead of asking.
  The plain setters pass NULL and behave as before, so the menu is
  unchanged.
- A --scan path gets its own prep task (dbscan_path_handler); a
  --scan-refresh resolves its path inside the load task, and the load
  reads the folder, the saved DAT and the overrides' paths once the
  header is parsed.  The worker gets copies of the item's strings.
- The prep's state is freed in one place.

Lane: database_scan_test "fs off main": six items that each stop
before their scan, on a threaded queue, with path_stat, path_is_valid,
path_is_directory, path_get_size and path_resolve_realpath wrapped:
no call on the main thread.

Not moved: the scan task's own push still stats the content folder
and the DAT (manual_content_scan_get_task_config), shared with the
menu.

* samples: the scan chain's lanes hold on Windows and without realpath

From a native Windows run of both tests:

- database_scan_test "fs off main" refreshed f_gone.lpl through
  "pl/../pl/", which only a realpath folds; the sample builds without
  RARCH_INTERNAL, where path_resolve_realpath() folds nothing, so the
  item was rightly not in the playlist folder.  It names the playlist
  by its plain path now, which the check still realpaths on the
  worker.
- "another case, a database's playlist" read the system name type
  after the chain ended, and the chain puts the Manual Scan settings
  back then.  The item callback records each scan's type instead.
- playlist_parity_test's header lane expected "/moved/snes"; autofix
  converts the suffix to the local form, "/moved\snes" on Windows.
- The worker lane's stress read required a core name of every read.
  Windows replaces the file with MoveFileExW, and an open that meets
  the replace fails: that read is an empty playlist, which has none.

---------

Co-authored-by: LibretroAdmin <[email protected]>
2026-11-04 19:34:05 +01:00

1889 lines
63 KiB
C

/* Parity oracle for playlist.c's read path (playlist_init /
* playlist_read_file), compiled from the shipping translation unit.
*
* Purpose: pin the reader's observable behaviour BEFORE the
* follow-up commits restructure it (callback driver to pull loop,
* then a budgeted resumable parse). Every lane asserts
* hand-derived expectations against fixtures this test writes, so
* a later behavioural drift fails here rather than in someone's
* library:
*
* json - the full schema: every header meta field, the
* scan record, and entries carrying every member
* the handler accepts, including subsystem rom
* arrays and runtime/last-played values; unknown
* members (flat and nested) are skipped.
* json partial - malformed JSON WARNS and keeps everything
* parsed up to the error; playlist_init still
* succeeds. Only allocation failure fails the
* read. This surprising contract is exactly why
* it is pinned.
* old format - six-line entry groups followed by the metadata
* block (default core, label display mode,
* thumbnail modes, sort mode), Windows and Unix
* line endings.
* compressed - the same JSON document behind the RZIP
* interface (transparent for uncompressed input,
* decompressing for rzip files).
* missing file - an absent playlist reads as an empty playlist,
* successfully.
* capacity - entries beyond config.capacity are dropped,
* entries within it survive.
*
* No threads anywhere on this path, so the sanitizer sweep is
* ASan+UBSan+LSan. */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <boolean.h>
#include <streams/interface_stream.h>
#include <streams/file_stream.h>
#include <vfs/vfs_implementation.h>
#include <streams/rzip_stream.h>
#include <vfs/vfs.h>
#include <libretro.h>
#include "../../playlist.h"
#include "../../verbosity.h"
/* ------------------------------------------------------------------ */
/* Stubs: the three externs playlist.c references outside the read */
/* path (dedup matching and core association resolution; none run */
/* during playlist_init). */
/* ------------------------------------------------------------------ */
#include "../../core_info.h"
#include <compat/strl.h>
#include <rthreads/rthreads.h>
bool core_info_find(const char *core_path, core_info_t **core_info)
{
(void)core_path;
if (core_info)
*core_info = NULL;
return false;
}
bool core_info_core_file_id_is_equal(const char *core_path_a,
const char *core_path_b)
{
(void)core_path_a;
(void)core_path_b;
return false;
}
bool play_feature_delivery_enabled(void)
{
return false;
}
/* verbosity.c console hooks (frontend driver not linked) */
void frontend_driver_attach_console(void) { }
void frontend_driver_detach_console(void) { }
/* ------------------------------------------------------------------ */
static char fixture_dir[256];
static unsigned failures = 0;
#define CHECK(cond, ...) \
do { \
if (!(cond)) \
{ \
fprintf(stderr, "FAIL %s:%d: ", __FILE__, __LINE__); \
fprintf(stderr, __VA_ARGS__); \
fprintf(stderr, "\n"); \
failures++; \
} \
} while (0)
static bool write_whole(const char *path, const char *data)
{
FILE *f = fopen(path, "wb");
if (!f)
return false;
if (data && *data)
fwrite(data, 1, strlen(data), f);
fclose(f);
return true;
}
static void config_defaults(playlist_config_t *config, const char *path)
{
memset(config, 0, sizeof(*config));
config->capacity = 512;
config->old_format = false;
config->compress = false;
config->fuzzy_archive_match = false;
config->autofix_paths = false;
playlist_config_set_path(config, path);
}
static bool streq(const char *a, const char *b)
{
if (!a && !b)
return true;
if (!a || !b)
return false;
return strcmp(a, b) == 0;
}
/* ------------------------------------------------------------------ */
/* Fixtures */
/* ------------------------------------------------------------------ */
/* Every member the handler accepts, plus unknown members it must
* skip (one flat, one nested object, one nested array). */
static const char json_full[] =
"{\n"
" \"version\": \"1.5\",\n"
" \"default_core_path\": \"/cores/core_a.so\",\n"
" \"default_core_name\": \"Core A\",\n"
" \"label_display_mode\": 2,\n"
" \"right_thumbnail_mode\": 3,\n"
" \"left_thumbnail_mode\": 2,\n"
" \"thumbnail_match_mode\": 1,\n"
" \"sort_mode\": 1,\n"
" \"base_content_directory\": \"/games\",\n"
" \"scan_content_dir\": \"/games/snes\",\n"
" \"scan_file_exts\": \"sfc|smc\",\n"
" \"scan_dat_file_path\": \"/dats/snes.dat\",\n"
" \"scan_database_name\": \"Nintendo - SNES\",\n"
" \"scan_search_recursively\": true,\n"
" \"scan_search_archives\": false,\n"
" \"scan_filter_dat_content\": true,\n"
" \"scan_overwrite_playlist\": false,\n"
" \"future_flat_member\": \"ignored\",\n"
" \"future_object\": { \"nested\": [1, 2, {\"deep\": true}] },\n"
" \"items\": [\n"
" {\n"
" \"path\": \"/games/snes/Game One.sfc\",\n"
" \"label\": \"Game One\",\n"
" \"core_path\": \"/cores/core_a.so\",\n"
" \"core_name\": \"Core A\",\n"
" \"crc32\": \"AABBCCDD|crc\",\n"
" \"db_name\": \"Nintendo - SNES.lpl\",\n"
" \"subsystem_ident\": \"sgb\",\n"
" \"subsystem_name\": \"Super Game Boy\",\n"
" \"subsystem_roms\": [ \"/games/snes/SGB.sfc\", \"/games/gb/Game.gb\" ],\n"
" \"runtime_hours\": 12,\n"
" \"runtime_minutes\": 34,\n"
" \"runtime_seconds\": 56,\n"
" \"last_played_year\": 2026,\n"
" \"last_played_month\": 8,\n"
" \"last_played_day\": 15,\n"
" \"last_played_hour\": 1,\n"
" \"last_played_minute\": 2,\n"
" \"last_played_second\": 3,\n"
" \"future_entry_member\": [ \"ignored\", { \"x\": 1 } ]\n"
" },\n"
" {\n"
" \"path\": \"/games/snes/Game Two.sfc\",\n"
" \"label\": \"\"\n"
" },\n"
" {\n"
" \"label\": \"Pathless Entry\",\n"
" \"core_name\": \"Core B\"\n"
" }\n"
" ]\n"
"}\n";
/* The same document, truncated mid-entry: everything before the
* cut must survive, and playlist_init must still succeed. */
static const char json_truncated[] =
"{\n"
" \"version\": \"1.5\",\n"
" \"default_core_path\": \"/cores/core_a.so\",\n"
" \"default_core_name\": \"Core A\",\n"
" \"items\": [\n"
" {\n"
" \"path\": \"/games/snes/Game One.sfc\",\n"
" \"label\": \"Game One\"\n"
" },\n"
" {\n"
" \"path\": \"/games/snes/Game Two.sfc\",\n"
" \"lab";
/* Old format: six lines per entry, then the metadata block the
* old writer appends. Mixed line endings on purpose. */
static const char old_format[] =
"/games/md/Alpha.md\r\n"
"Alpha\r\n"
"/cores/core_md.so\r\n"
"Core MD\r\n"
"11223344|crc\r\n"
"Sega - Mega Drive.lpl\r\n"
"/games/md/Beta.md\n"
"Beta\n"
"DETECT\n"
"DETECT\n"
"00000000|crc\n"
"Sega - Mega Drive.lpl\n"
"default_core_path = \"/cores/core_md.so\"\n"
"default_core_name = \"Core MD\"\n"
"label_display_mode = \"1\"\n"
"thumbnail_mode = \"2|3\"\n"
"sort_mode = \"1\"\n";
/* ------------------------------------------------------------------ */
/* Lanes */
/* ------------------------------------------------------------------ */
static void check_json_full_contents(playlist_t *pl, const char *lane)
{
const struct playlist_entry *e = NULL;
CHECK(playlist_size(pl) == 3, "%s: size %u, wanted 3",
lane, (unsigned)playlist_size(pl));
CHECK(streq(playlist_get_default_core_path(pl), "/cores/core_a.so"),
"%s: default_core_path", lane);
CHECK(streq(playlist_get_default_core_name(pl), "Core A"),
"%s: default_core_name", lane);
CHECK(playlist_get_label_display_mode(pl) == LABEL_DISPLAY_MODE_REMOVE_BRACKETS,
"%s: label_display_mode", lane);
CHECK(playlist_get_thumbnail_mode(pl, PLAYLIST_THUMBNAIL_RIGHT)
== PLAYLIST_THUMBNAIL_MODE_TITLE_SCREENS,
"%s: right_thumbnail_mode", lane);
CHECK(playlist_get_thumbnail_mode(pl, PLAYLIST_THUMBNAIL_LEFT)
== PLAYLIST_THUMBNAIL_MODE_SCREENSHOTS,
"%s: left_thumbnail_mode", lane);
CHECK(playlist_get_sort_mode(pl) == PLAYLIST_SORT_MODE_ALPHABETICAL,
"%s: sort_mode", lane);
CHECK(streq(playlist_get_scan_content_dir(pl), "/games/snes"),
"%s: scan_content_dir", lane);
CHECK(streq(playlist_get_scan_file_exts(pl), "sfc|smc"),
"%s: scan_file_exts", lane);
CHECK(streq(playlist_get_scan_dat_file_path(pl), "/dats/snes.dat"),
"%s: scan_dat_file_path", lane);
playlist_get_index(pl, 0, &e);
CHECK(e != NULL, "%s: entry 0 missing", lane);
if (e)
{
CHECK(streq(e->path, "/games/snes/Game One.sfc"), "%s: e0 path", lane);
CHECK(streq(e->label, "Game One"), "%s: e0 label", lane);
CHECK(streq(e->core_path, "/cores/core_a.so"), "%s: e0 core_path", lane);
CHECK(streq(e->core_name, "Core A"), "%s: e0 core_name", lane);
CHECK(streq(e->crc32, "AABBCCDD|crc"), "%s: e0 crc32", lane);
CHECK(streq(e->db_name, "Nintendo - SNES.lpl"), "%s: e0 db_name", lane);
CHECK(streq(e->subsystem_ident, "sgb"), "%s: e0 subsystem_ident", lane);
CHECK(streq(e->subsystem_name, "Super Game Boy"), "%s: e0 subsystem_name", lane);
CHECK(e->subsystem_roms && e->subsystem_roms->size == 2
&& streq(e->subsystem_roms->elems[0].data, "/games/snes/SGB.sfc")
&& streq(e->subsystem_roms->elems[1].data, "/games/gb/Game.gb"),
"%s: e0 subsystem_roms", lane);
CHECK(PLAYLIST_RUNTIME_HOURS(e) == 12 && PLAYLIST_RUNTIME_MINUTES(e) == 34
&& PLAYLIST_RUNTIME_SECONDS(e) == 56, "%s: e0 runtime", lane);
CHECK(PLAYLIST_LAST_PLAYED_YEAR(e) == 2026 && PLAYLIST_LAST_PLAYED_MONTH(e) == 8
&& PLAYLIST_LAST_PLAYED_DAY(e) == 15 && PLAYLIST_LAST_PLAYED_HOUR(e) == 1
&& PLAYLIST_LAST_PLAYED_MINUTE(e) == 2 && PLAYLIST_LAST_PLAYED_SECOND(e) == 3,
"%s: e0 last_played", lane);
}
e = NULL;
playlist_get_index(pl, 1, &e);
CHECK(e && streq(e->path, "/games/snes/Game Two.sfc"),
"%s: e1 path", lane);
/* An empty JSON string value is treated as absent: no
* allocation, member stays NULL. Pinned as-is. */
CHECK(e && !e->label, "%s: e1 empty label is NULL", lane);
CHECK(e && !e->core_path && !e->crc32, "%s: e1 absent members", lane);
e = NULL;
playlist_get_index(pl, 2, &e);
CHECK(e && !e->path && streq(e->label, "Pathless Entry")
&& streq(e->core_name, "Core B"),
"%s: e2 pathless entry survives", lane);
}
static void lane_json_full(void)
{
unsigned had = failures;
char path[512];
playlist_config_t config;
playlist_t *pl = NULL;
snprintf(path, sizeof(path), "%s/full.lpl", fixture_dir);
CHECK(write_whole(path, json_full), "fixture write");
config_defaults(&config, path);
pl = playlist_init(&config);
CHECK(pl != NULL, "json full: init failed");
if (!pl)
return;
check_json_full_contents(pl, "json full");
playlist_free(pl);
if (failures == had)
fprintf(stderr, "[pass] json full-schema lane\n");
}
static void lane_json_truncated(void)
{
unsigned had = failures;
char path[512];
playlist_config_t config;
playlist_t *pl = NULL;
const struct playlist_entry *e = NULL;
snprintf(path, sizeof(path), "%s/trunc.lpl", fixture_dir);
CHECK(write_whole(path, json_truncated), "fixture write");
config_defaults(&config, path);
pl = playlist_init(&config);
CHECK(pl != NULL,
"truncated JSON must warn, keep partial data and succeed");
if (!pl)
return;
CHECK(streq(playlist_get_default_core_path(pl), "/cores/core_a.so"),
"truncated: header meta before the cut survives");
CHECK(playlist_size(pl) >= 1, "truncated: first entry survives");
playlist_get_index(pl, 0, &e);
CHECK(e && streq(e->path, "/games/snes/Game One.sfc")
&& streq(e->label, "Game One"),
"truncated: entry 0 contents");
playlist_free(pl);
if (failures == had)
fprintf(stderr, "[pass] json truncated lane\n");
}
static void lane_old_format(void)
{
unsigned had = failures;
char path[512];
playlist_config_t config;
playlist_t *pl = NULL;
const struct playlist_entry *e = NULL;
snprintf(path, sizeof(path), "%s/old.lpl", fixture_dir);
CHECK(write_whole(path, old_format), "fixture write");
config_defaults(&config, path);
pl = playlist_init(&config);
CHECK(pl != NULL, "old format: init failed");
if (!pl)
return;
CHECK(playlist_size(pl) == 2, "old format: size %u, wanted 2",
(unsigned)playlist_size(pl));
playlist_get_index(pl, 0, &e);
CHECK(e && streq(e->path, "/games/md/Alpha.md")
&& streq(e->label, "Alpha")
&& streq(e->core_path, "/cores/core_md.so")
&& streq(e->core_name, "Core MD")
&& streq(e->crc32, "11223344|crc")
&& streq(e->db_name, "Sega - Mega Drive.lpl"),
"old format: entry 0 (CRLF endings)");
e = NULL;
playlist_get_index(pl, 1, &e);
CHECK(e && streq(e->path, "/games/md/Beta.md")
&& streq(e->core_path, "DETECT"),
"old format: entry 1 (LF endings)");
CHECK(streq(playlist_get_default_core_path(pl), "/cores/core_md.so")
&& streq(playlist_get_default_core_name(pl), "Core MD"),
"old format: default core metadata");
CHECK(playlist_get_label_display_mode(pl)
== LABEL_DISPLAY_MODE_REMOVE_PARENTHESES,
"old format: label_display_mode 1");
CHECK(playlist_get_thumbnail_mode(pl, PLAYLIST_THUMBNAIL_RIGHT)
== PLAYLIST_THUMBNAIL_MODE_SCREENSHOTS
&& playlist_get_thumbnail_mode(pl, PLAYLIST_THUMBNAIL_LEFT)
== PLAYLIST_THUMBNAIL_MODE_TITLE_SCREENS,
"old format: thumbnail_mode \"2|3\" is right|left");
CHECK(playlist_get_sort_mode(pl) == PLAYLIST_SORT_MODE_ALPHABETICAL,
"old format: sort_mode");
playlist_free(pl);
if (failures == had)
fprintf(stderr, "[pass] old-format lane\n");
}
static void lane_compressed(void)
{
#if defined(HAVE_COMPRESSION)
unsigned had = failures;
char path[512];
playlist_config_t config;
playlist_t *pl = NULL;
intfstream_t *stream = NULL;
snprintf(path, sizeof(path), "%s/comp.lpl", fixture_dir);
/* Write the full-schema document through the RZIP writer, so the
* on-disk bytes are an actual rzip container. */
stream = intfstream_open_rzip_file(path, RETRO_VFS_FILE_ACCESS_WRITE);
CHECK(stream != NULL, "rzip open for write");
if (!stream)
return;
CHECK(intfstream_write(stream, json_full,
(int64_t)strlen(json_full)) == (int64_t)strlen(json_full),
"rzip write");
intfstream_close(stream);
free(stream);
config_defaults(&config, path);
pl = playlist_init(&config);
CHECK(pl != NULL, "compressed: init failed");
if (!pl)
return;
check_json_full_contents(pl, "compressed");
playlist_free(pl);
if (failures == had)
fprintf(stderr, "[pass] compressed lane\n");
#else
fprintf(stderr, "[skip] compressed lane (no HAVE_COMPRESSION)\n");
#endif
}
static void lane_missing(void)
{
unsigned had = failures;
char path[512];
playlist_config_t config;
playlist_t *pl = NULL;
snprintf(path, sizeof(path), "%s/does_not_exist.lpl", fixture_dir);
config_defaults(&config, path);
pl = playlist_init(&config);
CHECK(pl != NULL, "missing file must init an empty playlist");
CHECK(pl && playlist_size(pl) == 0, "missing file: size 0");
playlist_free(pl);
if (failures == had)
fprintf(stderr, "[pass] missing-file lane\n");
}
static void lane_capacity(void)
{
unsigned had = failures;
char path[512];
char *doc = NULL;
size_t doc_cap = 64 * 1024;
size_t _len = 0;
unsigned i;
playlist_config_t config;
playlist_t *pl = NULL;
const struct playlist_entry *e = NULL;
if (!(doc = (char*)malloc(doc_cap)))
{
CHECK(false, "capacity: doc alloc");
return;
}
_len += (size_t)snprintf(doc + _len, doc_cap - _len,
"{\n \"version\": \"1.5\",\n \"items\": [\n");
for (i = 0; i < 8; i++)
_len += (size_t)snprintf(doc + _len, doc_cap - _len,
" { \"path\": \"/games/cap/%02u.bin\", \"label\": \"L%02u\" }%s\n",
i, i, (i < 7) ? "," : "");
_len += (size_t)snprintf(doc + _len, doc_cap - _len, " ]\n}\n");
snprintf(path, sizeof(path), "%s/cap.lpl", fixture_dir);
CHECK(write_whole(path, doc), "fixture write");
free(doc);
config_defaults(&config, path);
config.capacity = 5;
pl = playlist_init(&config);
CHECK(pl != NULL, "capacity: init failed");
if (!pl)
return;
CHECK(playlist_size(pl) == 5, "capacity: size %u, wanted 5",
(unsigned)playlist_size(pl));
playlist_get_index(pl, 4, &e);
CHECK(e && streq(e->path, "/games/cap/04.bin"),
"capacity: last surviving entry is the fifth");
playlist_free(pl);
if (failures == had)
fprintf(stderr, "[pass] capacity lane\n");
}
/* ------------------------------------------------------------------ */
/* Budgeted lanes: the resumable API must produce byte-identical */
/* playlists to the blocking path, yielding along the way. */
/* ------------------------------------------------------------------ */
static bool budget_countdown(void *ud)
{
int *k = (int*)ud;
if (*k <= 0)
return false;
(*k)--;
return true;
}
static char *big_fixture_doc(unsigned entries, const char *base_dir)
{
size_t cap = (size_t)entries * 160 + 512;
char *doc = (char*)malloc(cap);
size_t _len = 0;
unsigned i;
if (!doc)
return NULL;
_len += (size_t)snprintf(doc + _len, cap - _len,
"{\n \"version\": \"1.5\",\n"
" \"base_content_directory\": \"%s\",\n"
" \"items\": [\n", base_dir);
for (i = 0; i < entries; i++)
_len += (size_t)snprintf(doc + _len, cap - _len,
" { \"path\": \"%s/dir%03u/game%05u.bin\","
" \"label\": \"Game %05u\" }%s\n",
base_dir, i % 251, i, i, (i + 1 < entries) ? "," : "");
_len += (size_t)snprintf(doc + _len, cap - _len, " ]\n}\n");
return doc;
}
static void check_playlists_equal(playlist_t *a, playlist_t *b,
const char *lane)
{
size_t i, n = playlist_size(a);
CHECK(n == playlist_size(b), "%s: sizes differ (%u vs %u)",
lane, (unsigned)n, (unsigned)playlist_size(b));
if (n != playlist_size(b))
return;
for (i = 0; i < n; i++)
{
const struct playlist_entry *ea = NULL;
const struct playlist_entry *eb = NULL;
playlist_get_index(a, i, &ea);
playlist_get_index(b, i, &eb);
if (!ea || !eb
|| !streq(ea->path, eb->path)
|| !streq(ea->label, eb->label))
{
CHECK(false, "%s: entry %u differs", lane, (unsigned)i);
return;
}
}
CHECK(streq(playlist_get_default_core_path(a),
playlist_get_default_core_path(b)),
"%s: default_core_path differs", lane);
}
static void lane_budgeted_json(void)
{
char path[512];
char *doc = NULL;
playlist_config_t config;
playlist_t *blocking = NULL;
playlist_t *stepped = NULL;
playlist_parse_t *p = NULL;
unsigned had = failures;
unsigned yields = 0;
int r;
if (!(doc = big_fixture_doc(5000, "/games")))
{
CHECK(false, "budgeted json: doc alloc");
return;
}
snprintf(path, sizeof(path), "%s/big.lpl", fixture_dir);
CHECK(write_whole(path, doc), "fixture write");
free(doc);
config_defaults(&config, path);
config.capacity = 8192;
blocking = playlist_init(&config);
CHECK(blocking && playlist_size(blocking) == 5000,
"budgeted json: blocking reference");
p = playlist_parse_begin(&config);
CHECK(p != NULL, "budgeted json: begin");
for (;;)
{
int k = 4; /* four event batches per slice */
r = playlist_parse_step(p, budget_countdown, &k);
if (r != 0)
break;
yields++;
}
CHECK(r == 1, "budgeted json: step result %d", r);
stepped = playlist_parse_end(p);
CHECK(stepped != NULL, "budgeted json: end");
/* 5000 entries at ~14 events each vs 4*256-event slices: the
* parse cannot possibly fit one slice. */
CHECK(yields >= 10, "budgeted json: only %u yields", yields);
if (blocking && stepped)
check_playlists_equal(blocking, stepped, "budgeted json");
playlist_free(blocking);
playlist_free(stepped);
if (failures == had)
fprintf(stderr, "[pass] budgeted json lane (%u yields)\n", yields);
}
static void lane_budgeted_old_format(void)
{
char path[512];
playlist_config_t config;
playlist_t *blocking = NULL;
playlist_t *stepped = NULL;
playlist_parse_t *p = NULL;
unsigned had = failures;
unsigned yields = 0;
int r;
snprintf(path, sizeof(path), "%s/old.lpl", fixture_dir);
CHECK(write_whole(path, old_format), "fixture write");
config_defaults(&config, path);
blocking = playlist_init(&config);
p = playlist_parse_begin(&config);
CHECK(p != NULL, "budgeted old: begin");
for (;;)
{
int k = 1; /* one line group per slice */
r = playlist_parse_step(p, budget_countdown, &k);
if (r != 0)
break;
yields++;
}
CHECK(r == 1, "budgeted old: step result %d", r);
stepped = playlist_parse_end(p);
CHECK(stepped && blocking, "budgeted old: results");
CHECK(yields >= 1, "budgeted old: no yields at group granularity");
if (blocking && stepped)
check_playlists_equal(blocking, stepped, "budgeted old");
playlist_free(blocking);
playlist_free(stepped);
if (failures == had)
fprintf(stderr, "[pass] budgeted old-format lane (%u yields)\n", yields);
}
static void lane_budgeted_autofix(void)
{
char path[512];
char *doc = NULL;
playlist_config_t config;
playlist_t *blocking = NULL;
playlist_t *stepped = NULL;
playlist_parse_t *p = NULL;
const struct playlist_entry *e = NULL;
unsigned had = failures;
int r;
if (!(doc = big_fixture_doc(600, "/games")))
{
CHECK(false, "autofix: doc alloc");
return;
}
snprintf(path, sizeof(path), "%s/fix.lpl", fixture_dir);
CHECK(write_whole(path, doc), "fixture write");
config_defaults(&config, path);
config.capacity = 8192;
config.autofix_paths = true;
strlcpy_lit(config.base_content_directory, "/mnt/newgames",
sizeof(config.base_content_directory));
blocking = playlist_init(&config);
CHECK(blocking && playlist_size(blocking) == 600,
"autofix: blocking reference");
playlist_get_index(blocking, 0, &e);
CHECK(e && e->path && strncmp(e->path, "/mnt/newgames/", 14) == 0,
"autofix: blocking rewrote paths (got \"%s\")",
(e && e->path) ? e->path : "(null)");
/* The fixture write above rewrote the file (autofix saves), so
* regenerate it for the stepped run to see identical input. */
CHECK(write_whole(path, doc), "fixture rewrite");
free(doc);
p = playlist_parse_begin(&config);
CHECK(p != NULL, "autofix: begin");
for (;;)
{
int k = 2;
r = playlist_parse_step(p, budget_countdown, &k);
if (r != 0)
break;
}
CHECK(r == 1, "autofix: step result %d", r);
stepped = playlist_parse_end(p);
CHECK(stepped != NULL, "autofix: end");
if (blocking && stepped)
check_playlists_equal(blocking, stepped, "autofix");
playlist_free(blocking);
playlist_free(stepped);
if (failures == had)
fprintf(stderr, "[pass] budgeted autofix lane\n");
}
static void lane_abort_midway(void)
{
char path[512];
playlist_config_t config;
playlist_parse_t *p = NULL;
unsigned had = failures;
int k;
/* Reuse the big fixture from the budgeted lane. */
snprintf(path, sizeof(path), "%s/big.lpl", fixture_dir);
config_defaults(&config, path);
config.capacity = 8192;
p = playlist_parse_begin(&config);
CHECK(p != NULL, "abort: begin");
k = 2;
CHECK(playlist_parse_step(p, budget_countdown, &k) == 0,
"abort: expected a yield mid-parse");
/* Abandon with entries staged and the parser mid-document: LSan
* verifies nothing leaks. */
playlist_parse_abort(p);
/* Abort immediately after begin, and end-after-abort misuse
* guards. */
p = playlist_parse_begin(&config);
CHECK(p != NULL, "abort: second begin");
playlist_parse_abort(p);
CHECK(playlist_parse_end(NULL) == NULL, "end(NULL)");
CHECK(playlist_parse_step(NULL, NULL, NULL) == -1, "step(NULL)");
if (failures == had)
fprintf(stderr, "[pass] abort lane\n");
}
/* ------------------------------------------------------------------ */
/* Cache-reuse invariant. */
/* */
/* playlist_cached_is_reusable() decides whether the cached playlist */
/* can stand in for a request by comparing the size recorded at read */
/* time against path_get_size() now. Whatever the read path records */
/* must therefore be the ON-DISK size - which is not the same number */
/* the stream reports for a compressed playlist, where rzip yields */
/* the uncompressed size from its header. Recording the wrong one */
/* costs no correctness but defeats the cache completely: every menu */
/* navigation would re-read and re-parse the file. */
/* */
/* Observable via pointer identity: a reused cache returns the same */
/* playlist object, a defeated one a freshly parsed object. */
/* ------------------------------------------------------------------ */
static void check_cache_reuse(const char *path, bool compressed,
const char *base_content_dir, const char *lane)
{
playlist_config_t config;
playlist_t *first = NULL;
playlist_t *second = NULL;
config_defaults(&config, path);
config.capacity = 8192;
/* Match the on-disk format so the cached init does not rewrite
* the file underneath the comparison, and the recorded base
* content directory so reuse is not rejected before the size
* comparison is even reached. */
config.compress = compressed;
playlist_config_set_base_content_directory(&config, base_content_dir);
playlist_free_cached();
CHECK(playlist_init_cached(&config), "%s: first cached init", lane);
if (!(first = playlist_get_cached()))
{
CHECK(false, "%s: first cached playlist", lane);
return;
}
/* Mark the cached object in memory only. Pointer identity is
* not a sound observable here - a re-parse frees the old
* playlist and the allocator hands the same address straight
* back - but a marker cannot survive a re-read from disk. */
playlist_set_default_core_name(first, "CACHE_REUSE_SENTINEL");
CHECK(playlist_init_cached(&config), "%s: second cached init", lane);
second = playlist_get_cached();
CHECK(second && streq(playlist_get_default_core_name(second),
"CACHE_REUSE_SENTINEL"),
"%s: cache was not reused - the size recorded at read time "
"does not match path_get_size(), so every request re-parses",
lane);
playlist_free_cached();
}
/* A task on a worker writes the playlist file the menu has cached.
* The writer leaves a note for the main thread rather than touching
* the cached playlist, which the main thread frees and installs at
* will; the main thread applies the note when it next asks the cache.
* The worker's write keeps the file's size, so the note alone must
* keep the cache from serving what is on disk no longer. A stress
* round follows: the worker rewrites while the main thread reuses and
* frees the cache (under ASan, and TSan with SANITIZER=thread). */
struct cache_writer
{
const char *path;
unsigned rounds;
};
static void cache_writer_name(const char *path, const char *name)
{
playlist_config_t config;
playlist_t *pl;
config_defaults(&config, path);
if (!(pl = playlist_init(&config)))
return;
playlist_set_default_core_name(pl, name);
playlist_write_file(pl);
playlist_free(pl);
}
static void cache_writer_run(void *arg)
{
struct cache_writer *w = (struct cache_writer*)arg;
unsigned i;
for (i = 0; i < w->rounds; i++)
cache_writer_name(w->path, (i & 1) ? "Core X" : "Core Y");
}
static void lane_cache_worker_write(void)
{
char path[512];
playlist_config_t config;
struct cache_writer w;
sthread_t *t;
playlist_t *pl;
unsigned had = failures;
unsigned i;
snprintf(path, sizeof(path), "%s/worker.lpl", fixture_dir);
CHECK(write_whole(path, json_full), "worker lane: fixture write");
/* the writer's own form, so every later write has its size */
cache_writer_name(path, "Core Z");
config_defaults(&config, path);
playlist_free_cached();
CHECK(playlist_init_cached(&config), "worker lane: cached init");
pl = playlist_get_cached();
CHECK(pl && streq(playlist_get_default_core_name(pl), "Core Z"),
"worker lane: cached playlist");
w.path = path;
w.rounds = 1;
CHECK((t = sthread_create(cache_writer_run, &w)) != NULL,
"worker lane: writer thread");
if (t)
sthread_join(t);
CHECK(playlist_init_cached(&config), "worker lane: cached init after");
pl = playlist_get_cached();
CHECK(pl && streq(playlist_get_default_core_name(pl), "Core Y"),
"worker lane: a same-size write from a worker was served "
"from the stale cache");
/* concurrent: the worker writes while the main thread reuses,
* reads and frees */
w.rounds = 200;
if ((t = sthread_create(cache_writer_run, &w)))
{
for (i = 0; i < 400; i++)
{
/* Windows replaces the file with MoveFileExW, and an open
* that meets it fails: that read is an empty playlist. */
if (playlist_init_cached(&config) && (pl = playlist_get_cached()))
CHECK( playlist_get_default_core_name(pl) != NULL
#ifdef _WIN32
|| !playlist_size(pl)
#endif
, "worker lane: stress read");
if (i & 1)
playlist_free_cached();
}
sthread_join(t);
}
CHECK(playlist_init_cached(&config), "worker lane: final init");
pl = playlist_get_cached();
/* the last of 200 writes, round 199, wrote "Core X" */
CHECK(pl && streq(playlist_get_default_core_name(pl), "Core X"),
"worker lane: the cache missed the worker's last write");
playlist_free_cached();
if (failures == had)
fprintf(stderr, "[pass] worker-write cache lane\n");
}
/* The whole file, for comparing it before and after; caller frees. */
static char *slurp(const char *path, long *len)
{
char *buf = NULL;
FILE *f = fopen(path, "rb");
*len = -1;
if (!f)
return NULL;
if ( !fseek(f, 0, SEEK_END)
&& (*len = ftell(f)) >= 0
&& !fseek(f, 0, SEEK_SET)
&& (buf = (char*)malloc((size_t)*len + 1)))
{
if (fread(buf, 1, (size_t)*len, f) != (size_t)*len)
{
free(buf);
buf = NULL;
}
}
fclose(f);
return buf;
}
static playlist_t *header_read(const playlist_config_t *config)
{
playlist_parse_t *p = playlist_parse_begin_header(config);
if (!p)
return NULL;
if (playlist_parse_step(p, NULL, NULL) < 0)
{
playlist_parse_abort(p);
return NULL;
}
return playlist_parse_end(p);
}
/* playlist_parse_begin_header(): what comes ahead of the entries and
* nothing after; under a moved base content directory the scan record
* moves in memory while the file stays as it was; the result is never
* written; the old format, which has no scan settings, reads empty. */
static void lane_header_only(void)
{
char path[512];
playlist_config_t config;
playlist_t *pl;
char *before, *after;
long blen, alen;
unsigned had = failures;
snprintf(path, sizeof(path), "%s/header.lpl", fixture_dir);
CHECK(write_whole(path, json_full), "header lane: fixture write");
config_defaults(&config, path);
pl = header_read(&config);
CHECK(pl != NULL, "header lane: read");
if (pl)
{
CHECK(playlist_size(pl) == 0, "header lane: %u entries read",
(unsigned)playlist_size(pl));
CHECK(streq(playlist_get_default_core_name(pl), "Core A"),
"header lane: default core name");
CHECK(streq(playlist_get_scan_content_dir(pl), "/games/snes"),
"header lane: scan content dir");
playlist_free(pl);
}
/* a base moved since the save: autofix's move, in memory only */
before = slurp(path, &blen);
playlist_config_set_base_content_directory(&config, "/moved");
pl = header_read(&config);
/* the suffix in the local form, as autofix converts it */
CHECK(pl && streq(playlist_get_scan_content_dir(pl),
#ifdef _WIN32
"/moved\\snes"
#else
"/moved/snes"
#endif
), "header lane: scan content dir under the moved base");
if (pl)
{
/* and never written, even when asked */
playlist_set_default_core_name(pl, "Core Changed");
playlist_write_file(pl);
playlist_free(pl);
}
after = slurp(path, &alen);
CHECK(before && after && blen == alen && !memcmp(before, after, (size_t)blen),
"header lane: the file changed under a header read");
free(before);
free(after);
CHECK(write_whole(path, old_format), "header lane: old format write");
config_defaults(&config, path);
pl = header_read(&config);
CHECK(pl && playlist_size(pl) == 0
&& !playlist_get_scan_content_dir(pl),
"header lane: the old format reads empty");
playlist_free(pl);
if (failures == had)
fprintf(stderr, "[pass] header-only lane\n");
}
static void lane_cache_reuse(void)
{
char path[512];
unsigned had = failures;
/* Uncompressed JSON */
snprintf(path, sizeof(path), "%s/full.lpl", fixture_dir);
CHECK(write_whole(path, json_full), "fixture write");
check_cache_reuse(path, false, "/games", "cache reuse (json)");
/* Old format, with the configuration asking for the new one: the
* cached init converts the file on the spot. That rewrite
* changes the on-disk size out from under the stamp taken while
* reading, so this case is the one that catches a stamp left
* stale after a format or compression conversion - the state in
* which the cache can never be reused again. */
snprintf(path, sizeof(path), "%s/old.lpl", fixture_dir);
CHECK(write_whole(path, old_format), "fixture write");
check_cache_reuse(path, false, "", "cache reuse (old format converted)");
/* Autofix with a base differing in length: the pass rewrites
* the entries and SAVES the file, changing the on-disk size out
* from under the stamp taken at open - the format conversion's
* hazard on the read path's other write. */
snprintf(path, sizeof(path), "%s/fixed.lpl", fixture_dir);
CHECK(write_whole(path, json_full), "fixture write");
check_cache_reuse(path, false, "/nb", "cache reuse (after autofix save)");
#if defined(HAVE_COMPRESSION)
/* RZIP compressed: the case where the stream size and the
* on-disk size differ. */
{
intfstream_t *stream = NULL;
snprintf(path, sizeof(path), "%s/comp.lpl", fixture_dir);
stream = intfstream_open_rzip_file(path,
RETRO_VFS_FILE_ACCESS_WRITE);
CHECK(stream != NULL, "cache reuse: rzip open");
if (stream)
{
intfstream_write(stream, json_full, (int64_t)strlen(json_full));
intfstream_close(stream);
free(stream);
check_cache_reuse(path, true, "/games", "cache reuse (compressed)");
}
}
#endif
if (failures == had)
fprintf(stderr, "[pass] cache-reuse lane\n");
}
/* Switching playlists must never expose the previous one.
*
* Reported on MaterialUI/Android: open the N64 playlist, go back,
* open the NES one, and the N64 titles are listed under the NES
* heading - selecting a row launches the wrong system's content.
* The blocking init swaps the cache within one call; the deferred
* one yields, and the menu reads playlist_get_cached() while
* building its list. Invariant: from the moment a different
* playlist is requested the cache must never return the old one.
* NULL while the new one parses is correct and callers handle it. */
static void lane_switch_playlist_never_shows_previous(void)
{
char path_a[512];
char path_b[512];
char *doc_a = NULL;
char *doc_b = NULL;
playlist_config_t cfg_a;
playlist_config_t cfg_b;
playlist_t *cached = NULL;
unsigned had = failures;
unsigned yields = 0;
int r;
/* Two distinct playlists, distinguishable by their entries. */
if (!(doc_a = big_fixture_doc(400, "/games/n64")))
{
CHECK(false, "switch lane: fixture alloc");
return;
}
if (!(doc_b = big_fixture_doc(400, "/games/nes")))
{
free(doc_a);
CHECK(false, "switch lane: fixture alloc");
return;
}
snprintf(path_a, sizeof(path_a), "%s/n64.lpl", fixture_dir);
snprintf(path_b, sizeof(path_b), "%s/nes.lpl", fixture_dir);
CHECK(write_whole(path_a, doc_a), "fixture write");
CHECK(write_whole(path_b, doc_b), "fixture write");
free(doc_a);
free(doc_b);
config_defaults(&cfg_a, path_a);
cfg_a.capacity = 8192;
playlist_config_set_base_content_directory(&cfg_a, "/games/n64");
config_defaults(&cfg_b, path_b);
cfg_b.capacity = 8192;
playlist_config_set_base_content_directory(&cfg_b, "/games/nes");
playlist_free_cached();
/* Step 1-2: open the first playlist. */
CHECK(playlist_init_cached(&cfg_a), "switch lane: first init");
cached = playlist_get_cached();
CHECK(cached && streq(playlist_get_conf_path(cached), path_a),
"switch lane: first playlist not cached");
/* Step 3-4: open the second. Budget it so it MUST yield part
* way, which is the window the bug lived in. */
for (;;)
{
int k = 2;
r = playlist_init_cached_deferred(&cfg_b, budget_countdown, &k);
/* The assertion, checked on every single yield: whatever the
* cache holds now, it is not the playlist we navigated away
* from. */
cached = playlist_get_cached();
if (cached)
CHECK(streq(playlist_get_conf_path(cached), path_b),
"switch lane: while loading \"%s\", the cache still "
"returned \"%s\" - the menu would draw the previous "
"playlist's entries under the new one's heading",
path_b, playlist_get_conf_path(cached));
if (r != 0)
break;
yields++;
}
CHECK(r == 1, "switch lane: deferred init returned %d", r);
CHECK(yields > 0,
"switch lane: the parse never yielded, so the window this "
"lane exists to cover was not exercised");
/* Step 5: the second playlist, and its own entries. */
cached = playlist_get_cached();
CHECK(cached != NULL, "switch lane: nothing cached at the end");
if (cached)
{
const struct playlist_entry *e = NULL;
CHECK(streq(playlist_get_conf_path(cached), path_b),
"switch lane: wrong playlist cached at the end");
CHECK(playlist_size(cached) == 400,
"switch lane: %u entries, wanted 400",
(unsigned)playlist_size(cached));
playlist_get_index(cached, 0, &e);
CHECK(e && e->path && strncmp(e->path, "/games/nes/", 11) == 0,
"switch lane: first entry is \"%s\", not from the "
"requested playlist",
(e && e->path) ? e->path : "(null)");
}
playlist_free_cached();
if (failures == had)
fprintf(stderr, "[pass] playlist-switch lane (%u yields)\n",
yields);
}
/* Same invariant under the impatient case: the user opens one
* playlist, backs out and opens another before the first has
* finished loading. Each request supersedes the last, and at no
* point may the cache hand back a playlist that is not the one
* currently being asked for. */
static void lane_switch_supersedes_pending(void)
{
char path_a[512], path_b[512], path_c[512];
playlist_config_t cfg_a, cfg_b, cfg_c;
playlist_t *cached = NULL;
unsigned had = failures;
char *doc = NULL;
int k, r;
if (!(doc = big_fixture_doc(400, "/games/sw")))
{
CHECK(false, "supersede lane: fixture alloc");
return;
}
snprintf(path_a, sizeof(path_a), "%s/sw_a.lpl", fixture_dir);
snprintf(path_b, sizeof(path_b), "%s/sw_b.lpl", fixture_dir);
snprintf(path_c, sizeof(path_c), "%s/sw_c.lpl", fixture_dir);
CHECK(write_whole(path_a, doc), "fixture write");
CHECK(write_whole(path_b, doc), "fixture write");
CHECK(write_whole(path_c, doc), "fixture write");
free(doc);
config_defaults(&cfg_a, path_a);
cfg_a.capacity = 8192;
playlist_config_set_base_content_directory(&cfg_a, "/games/sw");
config_defaults(&cfg_b, path_b);
cfg_b.capacity = 8192;
playlist_config_set_base_content_directory(&cfg_b, "/games/sw");
config_defaults(&cfg_c, path_c);
cfg_c.capacity = 8192;
playlist_config_set_base_content_directory(&cfg_c, "/games/sw");
playlist_free_cached();
/* Start A, yield part way. */
k = 2;
r = playlist_init_cached_deferred(&cfg_a, budget_countdown, &k);
CHECK(r == 0, "supersede lane: A did not yield (%d)", r);
cached = playlist_get_cached();
CHECK(!cached, "supersede lane: something was cached mid-parse");
/* Switch to B before A finishes, yield again. */
k = 2;
r = playlist_init_cached_deferred(&cfg_b, budget_countdown, &k);
cached = playlist_get_cached();
if (cached)
CHECK(streq(playlist_get_conf_path(cached), path_b),
"supersede lane: cache returned \"%s\" while B was "
"loading", playlist_get_conf_path(cached));
/* And again to C, then run it out. */
for (;;)
{
k = 2;
r = playlist_init_cached_deferred(&cfg_c, budget_countdown, &k);
cached = playlist_get_cached();
if (cached)
CHECK(streq(playlist_get_conf_path(cached), path_c),
"supersede lane: cache returned \"%s\" while C was "
"the requested playlist",
playlist_get_conf_path(cached));
if (r != 0)
break;
}
CHECK(r == 1, "supersede lane: C did not complete (%d)", r);
cached = playlist_get_cached();
CHECK(cached && streq(playlist_get_conf_path(cached), path_c),
"supersede lane: C is not the cached playlist at the end");
playlist_free_cached();
if (failures == had)
fprintf(stderr, "[pass] playlist-supersede lane\n");
}
/* Android/SAF timing.
*
* Local stdio reads a playlist inside the first budgeted slice, so
* the yield path is never taken and its bugs are invisible - both
* field reports came from Android and neither reproduced on a PC.
* Content outside the app sandbox goes through SAF, which RetroArch
* reaches through a VFS, and it is slow enough that the parse really
* stops part way. This installs such a VFS through the seam the
* frontend uses and drives the same navigation over it. */
/* Cap on a single read, whatever was asked for. */
#define SAF_SHORT_READ 512
static unsigned saf_read_calls;
static int64_t saf_read(libretro_vfs_implementation_file *stream,
void *s, uint64_t len)
{
saf_read_calls++;
if (len > SAF_SHORT_READ)
len = SAF_SHORT_READ;
return retro_vfs_file_read_impl(stream, s, len);
}
static struct retro_vfs_interface saf_vfs;
static struct retro_vfs_interface_info saf_vfs_info;
static void saf_vfs_install(void)
{
saf_vfs.get_path = retro_vfs_file_get_path_impl;
saf_vfs.open = retro_vfs_file_open_impl;
saf_vfs.close = retro_vfs_file_close_impl;
saf_vfs.size = retro_vfs_file_size_impl;
saf_vfs.tell = retro_vfs_file_tell_impl;
saf_vfs.seek = retro_vfs_file_seek_impl;
saf_vfs.read = saf_read; /* the slow bit */
saf_vfs.write = retro_vfs_file_write_impl;
saf_vfs.flush = retro_vfs_file_flush_impl;
saf_vfs.remove = retro_vfs_file_remove_impl;
saf_vfs.rename = retro_vfs_file_rename_impl;
saf_vfs.truncate = retro_vfs_file_truncate_impl;
saf_vfs.stat = retro_vfs_stat_impl;
saf_vfs.mkdir = retro_vfs_mkdir_impl;
saf_vfs.opendir = retro_vfs_opendir_impl;
saf_vfs.readdir = retro_vfs_readdir_impl;
saf_vfs.dirent_get_name = retro_vfs_dirent_get_name_impl;
saf_vfs.dirent_is_dir = retro_vfs_dirent_is_dir_impl;
saf_vfs.closedir = retro_vfs_closedir_impl;
saf_vfs_info.required_interface_version = 3;
saf_vfs_info.iface = &saf_vfs;
filestream_vfs_init(&saf_vfs_info);
}
static void saf_vfs_remove(void)
{
struct retro_vfs_interface_info none;
none.required_interface_version = 0;
none.iface = NULL;
filestream_vfs_init(&none);
}
static void lane_saf_slow_reads(void)
{
char path_a[512];
char path_b[512];
char *doc = NULL;
playlist_config_t cfg_a;
playlist_config_t cfg_b;
playlist_t *cached = NULL;
unsigned had = failures;
unsigned yields = 0;
int r;
if (!(doc = big_fixture_doc(600, "/games/saf")))
{
CHECK(false, "saf lane: fixture alloc");
return;
}
snprintf(path_a, sizeof(path_a), "%s/saf_a.lpl", fixture_dir);
snprintf(path_b, sizeof(path_b), "%s/saf_b.lpl", fixture_dir);
CHECK(write_whole(path_a, doc), "fixture write");
CHECK(write_whole(path_b, doc), "fixture write");
free(doc);
config_defaults(&cfg_a, path_a);
cfg_a.capacity = 8192;
playlist_config_set_base_content_directory(&cfg_a, "/games/saf");
config_defaults(&cfg_b, path_b);
cfg_b.capacity = 8192;
playlist_config_set_base_content_directory(&cfg_b, "/games/saf");
playlist_free_cached();
saf_vfs_install();
saf_read_calls = 0;
CHECK(playlist_init_cached(&cfg_a), "saf lane: first init");
CHECK(saf_read_calls > 1,
"the slow VFS was not used - %u reads", saf_read_calls);
/* Now switch, under the same slow reads. */
for (;;)
{
int k = 2;
r = playlist_init_cached_deferred(&cfg_b,
budget_countdown, &k);
cached = playlist_get_cached();
if (cached)
CHECK(streq(playlist_get_conf_path(cached), path_b),
"saf lane: cache returned \"%s\" while loading "
"\"%s\"", playlist_get_conf_path(cached), path_b);
if (r != 0)
break;
yields++;
}
CHECK(r == 1, "saf lane: deferred init returned %d", r);
CHECK(yields > 0,
"saf lane: the parse never yielded even over a slow VFS - "
"this lane is no longer covering the Android timing it "
"exists for");
cached = playlist_get_cached();
CHECK(cached && streq(playlist_get_conf_path(cached), path_b),
"saf lane: wrong playlist cached at the end");
CHECK(cached && playlist_size(cached) == 600,
"saf lane: %u entries, wanted 600",
cached ? (unsigned)playlist_size(cached) : 0);
saf_vfs_remove();
playlist_free_cached();
if (failures == had)
fprintf(stderr, "[pass] SAF slow-read lane (%u yields, %u reads)\n",
yields, saf_read_calls);
}
/* The pump: a yielded parse must finish without user input.
*
* ENTRIES_NEED_REFRESH is consumed only in
* generic_menu_entry_action(), so before menu_driver_iterate() got a
* pump, a read that yielded stayed unfinished until the next
* keypress - the playlist came up empty and populated only after
* backing out and re-entering. This drives the same
* continue/finish pair the pump calls, over the slow VFS, and
* asserts that repeated frames alone complete it. */
static void lane_pump_completes_without_input(void)
{
char path_a[512];
char path_b[512];
char *doc = NULL;
playlist_config_t cfg_a;
playlist_config_t cfg_b;
playlist_t *cached = NULL;
unsigned had = failures;
unsigned frames = 0;
int r;
if (!(doc = big_fixture_doc(600, "/games/pump")))
{
CHECK(false, "pump lane: fixture alloc");
return;
}
snprintf(path_a, sizeof(path_a), "%s/pump_a.lpl", fixture_dir);
snprintf(path_b, sizeof(path_b), "%s/pump_b.lpl", fixture_dir);
CHECK(write_whole(path_a, doc), "fixture write");
CHECK(write_whole(path_b, doc), "fixture write");
free(doc);
config_defaults(&cfg_a, path_a);
cfg_a.capacity = 8192;
playlist_config_set_base_content_directory(&cfg_a, "/games/pump");
config_defaults(&cfg_b, path_b);
cfg_b.capacity = 8192;
playlist_config_set_base_content_directory(&cfg_b, "/games/pump");
playlist_free_cached();
saf_vfs_install();
CHECK(playlist_init_cached(&cfg_a), "pump lane: first init");
/* The first touch, as the displaylist makes it: one budgeted
* slice, which over SAF-speed reads does not finish. */
{
int k = 2;
r = playlist_init_cached_deferred(&cfg_b, budget_countdown, &k);
}
CHECK(r == 0,
"pump lane: the first slice finished the read, so this lane "
"is not exercising the case it exists for");
CHECK(playlist_init_cached_pending(),
"pump lane: nothing reported as pending after a yield");
/* Now only frames - no further requests, no input at all. */
while (playlist_init_cached_pending() && frames < 10000)
{
int k = 2;
frames++;
r = playlist_init_cached_continue(budget_countdown, &k);
if (r > 0)
playlist_init_cached_finish();
else if (r < 0)
break;
}
CHECK(frames > 0, "pump lane: no frames were needed");
CHECK(!playlist_init_cached_pending(),
"pump lane: still pending after %u frames - a read that "
"yields never completes on its own", frames);
cached = playlist_get_cached();
CHECK(cached && streq(playlist_get_conf_path(cached), path_b),
"pump lane: the pump did not install the requested "
"playlist");
CHECK(cached && playlist_size(cached) == 600,
"pump lane: %u entries, wanted 600",
cached ? (unsigned)playlist_size(cached) : 0);
saf_vfs_remove();
playlist_free_cached();
if (failures == had)
fprintf(stderr, "[pass] pump lane (%u frames, no input)\n",
frames);
}
/* Issue #19427 (large playlists flashing "No playlist entries
* available" on Android): the rebuild after a completed deferred
* read must be served from the cache just installed. A restart
* there frees that playlist and begins the read over, one
* placeholder flash per completion; small playlists hide the cycle
* by finishing inside one slice. This drives the pump's frame
* shape - yielding first touch, frames to completion, re-request -
* and asserts no new parse, sentinel still present. */
static void lane_rebuild_reuses_deferred_install(void)
{
char path[512];
char *doc = NULL;
playlist_config_t cfg;
playlist_t *cached = NULL;
unsigned had = failures;
unsigned frames = 0;
int r;
/* The file records a base_content_directory; the config has
* autofix off and no base - the #19427 shape. A fresh parse
* leaves the record alone, so the cache must be reusable. */
if (!(doc = big_fixture_doc(600, "/games/recorded_base")))
{
CHECK(false, "rebuild-reuse lane: fixture alloc");
return;
}
snprintf(path, sizeof(path), "%s/rebuild.lpl", fixture_dir);
CHECK(write_whole(path, doc), "fixture write");
free(doc);
config_defaults(&cfg, path);
cfg.capacity = 8192;
playlist_config_set_base_content_directory(&cfg, "");
playlist_free_cached();
saf_vfs_install();
/* First touch, as the displaylist makes it: one budgeted slice,
* which over SAF-speed reads does not finish. */
{
int k = 2;
r = playlist_init_cached_deferred(&cfg, budget_countdown, &k);
}
CHECK(r == 0,
"rebuild-reuse lane: the first slice finished the read, so "
"this lane is not exercising the case it exists for");
/* Frames to completion, as menu_driver_pump_pending() drives. */
while (playlist_init_cached_pending() && frames < 10000)
{
int k = 2;
frames++;
r = playlist_init_cached_continue(budget_countdown, &k);
if (r > 0)
playlist_init_cached_finish();
else if (r < 0)
break;
}
CHECK(!playlist_init_cached_pending(),
"rebuild-reuse lane: read never completed");
if (!(cached = playlist_get_cached()))
{
CHECK(false, "rebuild-reuse lane: nothing installed");
saf_vfs_remove();
return;
}
/* In-memory sentinel: cannot survive a re-read from disk. */
playlist_set_default_core_name(cached, "REBUILD_REUSE_SENTINEL");
/* The rebuild: same request, same config. */
{
int k = 2;
r = playlist_init_cached_deferred(&cfg, budget_countdown, &k);
}
CHECK(r == 1,
"rebuild-reuse lane: re-request was not served from the "
"cache (r=%d) - the rebuild frees the playlist it just "
"installed and starts the read over", r);
CHECK(!playlist_init_cached_pending(),
"rebuild-reuse lane: re-request left a parse pending");
cached = playlist_get_cached();
CHECK(cached && streq(playlist_get_default_core_name(cached),
"REBUILD_REUSE_SENTINEL"),
"rebuild-reuse lane: cache was re-read from disk instead "
"of reused");
saf_vfs_remove();
playlist_free_cached();
if (failures == had)
fprintf(stderr,
"[pass] rebuild-reuse lane (%u frames to install)\n",
frames);
}
/* --- a copy that cannot be made must not take the old value with it -- */
/* strdup() is wrapped so the failure is produced on demand. Only the
* copies playlist.c makes while replacing an entry field are denied;
* everything else the harness needs keeps working. */
static bool deny_strdup;
/* Or denied from the Nth copy on: -1 is off, 0 denies the next one. */
static int strdup_allowance = -1;
char *__real_strdup(const char *s);
char *__wrap_strdup(const char *s)
{
if (deny_strdup)
return NULL;
if (strdup_allowance >= 0)
{
if (strdup_allowance == 0)
return NULL;
strdup_allowance--;
}
return __real_strdup(s);
}
/* playlist_update() used to free an entry's string and then assign the
* result of strdup(). With no memory to copy into, the field was left
* NULL and the entry marked modified, so a complete entry became an
* incomplete one and the next write put that on disk. The update has to
* leave the entry exactly as it found it instead. */
static void lane_update_survives_failed_copy(void)
{
unsigned had = failures;
playlist_config_t cfg;
playlist_t *pl;
struct playlist_entry push;
struct playlist_entry upd;
const struct playlist_entry *got = NULL;
char path[512];
memset(&cfg, 0, sizeof(cfg));
snprintf(path, sizeof(path), "%s/oom.lpl", fixture_dir);
strlcpy(cfg.path, path, sizeof(cfg.path));
cfg.capacity = 16;
if (!(pl = playlist_init(&cfg)))
{
CHECK(false, "playlist_init failed");
return;
}
memset(&push, 0, sizeof(push));
push.path = (char*)"/games/game.bin";
push.label = (char*)"Original Label";
push.core_path = (char*)"/cores/core.so";
push.core_name = (char*)"Original Core";
playlist_push(pl, &push);
CHECK(playlist_size(pl) == 1, "the entry was not pushed");
playlist_get_index(pl, 0, &got);
CHECK(got && got->label && !strcmp(got->label, "Original Label"),
"the pushed label is not what was pushed");
memset(&upd, 0, sizeof(upd));
upd.label = (char*)"Replacement Label";
upd.core_name = (char*)"Replacement Core";
deny_strdup = true;
playlist_update(pl, 0, &upd);
deny_strdup = false;
playlist_get_index(pl, 0, &got);
CHECK(got != NULL, "the entry disappeared");
if (got)
{
CHECK(got->label != NULL,
"a failed copy left the label NULL instead of keeping it");
CHECK(got->label && !strcmp(got->label, "Original Label"),
"a failed copy did not keep the label that was there");
CHECK(got->core_name != NULL,
"a failed copy left the core name NULL instead of keeping it");
CHECK(got->path && !strcmp(got->path, "/games/game.bin"),
"a failed copy disturbed a field it was not replacing");
}
/* With memory again, the same update lands. */
playlist_update(pl, 0, &upd);
playlist_get_index(pl, 0, &got);
CHECK(got && got->label && !strcmp(got->label, "Replacement Label"),
"the update did not land once the copy could be made");
playlist_free(pl);
if (failures == had)
fprintf(stderr, " [pass] a failed copy keeps the entry it could not replace\n");
}
/* playlist_push_runtime() shifted every entry up and then filled the
* new top one with strdup() results it never checked, so a copy that
* failed partway left the list one entry longer with no path or no
* core on top - and the next write put that on disk. The strings are
* now made first, and a push that cannot make them leaves the list as
* it was. Every point the copies can fail at is tried: for each, the
* push either reports failure with the list untouched, or lands
* complete. */
static void lane_push_runtime_survives_failed_copy(void)
{
unsigned had = failures;
int allow;
for (allow = 0; allow < 8; allow++)
{
playlist_config_t cfg;
playlist_t *pl;
struct playlist_entry push;
struct playlist_entry rt;
const struct playlist_entry *got = NULL;
char path[512];
bool ok;
memset(&cfg, 0, sizeof(cfg));
snprintf(path, sizeof(path), "%s/oom_rt.lpl", fixture_dir);
strlcpy(cfg.path, path, sizeof(cfg.path));
cfg.capacity = 16;
if (!(pl = playlist_init(&cfg)))
{
CHECK(false, "playlist_init failed");
return;
}
memset(&push, 0, sizeof(push));
push.path = (char*)"/games/first.bin";
push.label = (char*)"First";
push.core_path = (char*)"/cores/core.so";
push.core_name = (char*)"Core";
playlist_push(pl, &push);
CHECK(playlist_size(pl) == 1, "the first entry was not pushed");
memset(&rt, 0, sizeof(rt));
rt.path = (char*)"/games/second.bin";
rt.core_path = (char*)"/cores/core.so";
rt.runtime_str = (char*)"00:01:00";
rt.last_played_str = (char*)"2026-09-29 19:00:00";
strdup_allowance = allow;
ok = playlist_push_runtime(pl, &rt);
strdup_allowance = -1;
if (!ok)
{
CHECK(playlist_size(pl) == 1,
"after %d copies: a failed push changed the number of entries",
allow);
playlist_get_index(pl, 0, &got);
CHECK(got && got->path && !strcmp(got->path, "/games/first.bin"),
"after %d copies: a failed push disturbed the entry on top",
allow);
}
else
{
CHECK(playlist_size(pl) == 2,
"after %d copies: a push that reported success added nothing",
allow);
playlist_get_index(pl, 0, &got);
CHECK(got && got->path && !strcmp(got->path, "/games/second.bin"),
"after %d copies: a push that reported success has no path on top",
allow);
CHECK(got && got->core_path && !strcmp(got->core_path, "/cores/core.so"),
"after %d copies: a push that reported success has no core on top",
allow);
CHECK(got && got->runtime_str && !strcmp(got->runtime_str, "00:01:00"),
"after %d copies: a push that reported success lost its runtime",
allow);
CHECK(got && got->last_played_str
&& !strcmp(got->last_played_str, "2026-09-29 19:00:00"),
"after %d copies: a push that reported success lost its last-played",
allow);
playlist_get_index(pl, 1, &got);
CHECK(got && got->path && !strcmp(got->path, "/games/first.bin"),
"after %d copies: the entry that was on top did not move down intact",
allow);
}
playlist_free(pl);
}
if (failures == had)
fprintf(stderr, " [pass] a push that cannot copy its strings leaves the list alone\n");
}
/* A pushed entry starts with no thumbnail-name flags. The push
* shifts the list down one and builds the new entry in slot 0 with
* read-modify-write setters, so the flag bits it never cleared came
* from the entry that had been on top. */
static void lane_push_starts_without_thumbnail_flags(void)
{
unsigned had = failures;
playlist_config_t cfg;
playlist_t *pl;
struct playlist_entry push;
char path[512];
memset(&cfg, 0, sizeof(cfg));
snprintf(path, sizeof(path), "%s/thumb_flags.lpl", fixture_dir);
strlcpy(cfg.path, path, sizeof(cfg.path));
cfg.capacity = 16;
if (!(pl = playlist_init(&cfg)))
{
CHECK(false, "playlist_init failed");
return;
}
memset(&push, 0, sizeof(push));
push.path = (char*)"/games/first.bin";
push.core_path = (char*)"/cores/core.so";
push.core_name = (char*)"Core";
playlist_push(pl, &push);
playlist_update_thumbnail_name_flag(pl, 0,
(enum playlist_thumbnail_name_flags)(PLAYLIST_THUMBNAIL_FLAG_FULL_NAME
| PLAYLIST_THUMBNAIL_FLAG_STD_NAME
| PLAYLIST_THUMBNAIL_FLAG_SHORT_NAME
| PLAYLIST_THUMBNAIL_FLAG_NONE));
push.path = (char*)"/games/second.bin";
playlist_push(pl, &push);
CHECK(playlist_size(pl) == 2, "the second entry was not pushed");
CHECK(playlist_get_curr_thumbnail_name_flag(pl, 0)
== PLAYLIST_THUMBNAIL_FLAG_INVALID,
"a new entry inherited thumbnail flags 0x%x from the old top entry",
(unsigned)playlist_get_curr_thumbnail_name_flag(pl, 0));
CHECK(playlist_get_curr_thumbnail_name_flag(pl, 1)
== (PLAYLIST_THUMBNAIL_FLAG_FULL_NAME
| PLAYLIST_THUMBNAIL_FLAG_STD_NAME
| PLAYLIST_THUMBNAIL_FLAG_SHORT_NAME
| PLAYLIST_THUMBNAIL_FLAG_NONE),
"the old top entry lost its thumbnail flags when it moved down");
playlist_free(pl);
if (failures == had)
fprintf(stderr, " [pass] a pushed entry starts without thumbnail flags\n");
}
int main(int argc, char *argv[])
{
char cmd[600];
(void)argc;
(void)argv;
verbosity_enable();
snprintf(fixture_dir, sizeof(fixture_dir),
"/tmp/playlist_fixture_%ld", (long)getpid());
snprintf(cmd, sizeof(cmd), "mkdir -p %s", fixture_dir);
if (system(cmd) != 0)
{
fprintf(stderr, "fixture mkdir failed\n");
return 1;
}
lane_json_full();
lane_json_truncated();
lane_old_format();
lane_compressed();
lane_missing();
lane_capacity();
lane_budgeted_json();
lane_budgeted_old_format();
lane_budgeted_autofix();
lane_abort_midway();
lane_cache_reuse();
lane_cache_worker_write();
lane_header_only();
lane_switch_playlist_never_shows_previous();
lane_switch_supersedes_pending();
lane_saf_slow_reads();
lane_pump_completes_without_input();
lane_rebuild_reuses_deferred_install();
lane_update_survives_failed_copy();
lane_push_runtime_survives_failed_copy();
lane_push_starts_without_thumbnail_flags();
snprintf(cmd, sizeof(cmd), "rm -rf %s", fixture_dir);
if (system(cmd) != 0) { }
if (failures)
{
fprintf(stderr, "FAIL playlist_parity_test: %u failures\n", failures);
return 1;
}
fprintf(stderr, "PASS playlist_parity_test\n");
return 0;
}