diff --git a/.travis.yml b/.travis.yml
index 99df7d698..bcf1e5b9d 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -2,6 +2,28 @@ language: cpp
 
 matrix:
   include:
+    # Ubuntu Bionic (18.04) with GCC 7
+    - os: linux
+      dist: bionic
+      addons:
+        apt:
+          sources:
+            - sourceline: 'ppa:deadsnakes/ppa' # for Python 3.7 (required by Meson)
+          packages:
+            - libgtest-dev
+            - libboost-dev
+            - python3.6
+            - python3-urllib3
+            - ninja-build
+      before_install:
+        - wget https://bootstrap.pypa.io/get-pip.py
+        - /usr/bin/python3.6 get-pip.py --user
+      install:
+        - /usr/bin/python3.6 $HOME/.local/bin/pip install --user meson
+      env:
+        - MATRIX_EVAL="export PATH=\$HOME/.local/bin:\$PATH"
+
+    # Ubuntu Trusty (16.04) with GCC 6
     - os: linux
       dist: trusty
       addons:
@@ -25,8 +47,9 @@ matrix:
         - /usr/bin/python3.6 $HOME/.local/bin/pip install --user meson
       env:
         # use gold as workaround for https://sourceware.org/bugzilla/show_bug.cgi?id=17068
-        - MATRIX_EVAL="export CC=gcc-6 CXX=g++-6 LDFLAGS=-fuse-ld=gold PATH=$HOME/.local/bin:$PATH"
+        - MATRIX_EVAL="export CC='ccache gcc-6' CXX='ccache g++-6' LDFLAGS=-fuse-ld=gold PATH=\$HOME/.local/bin:\$PATH"
 
+    # Ubuntu Trusty (16.04) with GCC 8
     - os: linux
       dist: trusty
       addons:
@@ -50,25 +73,37 @@ matrix:
         - /usr/bin/python3.6 $HOME/.local/bin/pip install --user meson
       env:
         # use gold as workaround for https://sourceware.org/bugzilla/show_bug.cgi?id=17068
-        - MATRIX_EVAL="export CC=gcc-8 CXX=g++-8 LDFLAGS=-fuse-ld=gold PATH=$HOME/.local/bin:$PATH"
+        - MATRIX_EVAL="export CC='ccache gcc-8' CXX='ccache g++-8' LDFLAGS=-fuse-ld=gold PATH=\$HOME/.local/bin:\$PATH"
 
     - os: osx
-      osx_image: xcode9.3beta
+      osx_image: xcode9.4
+      addons:
+        homebrew:
+          packages:
+            - ccache
+            - meson
       env:
-        - MATRIX_EVAL=""
+        - MATRIX_EVAL="export PATH=/usr/local/opt/ccache/libexec:$PATH HOMEBREW_NO_ANALYTICS=1"
 
 cache:
-  - apt
-  - ccache
+  apt: true
+  ccache: true
+  directories:
+    - $HOME/Library/Caches/Homebrew
+
+before_cache:
+  - test "$TRAVIS_OS_NAME" != "osx" || brew cleanup
 
 before_install:
   - eval "${MATRIX_EVAL}"
-  # C++14
-  - test "$TRAVIS_OS_NAME" != "osx" || brew update
 
 install:
   # C++14
-  - test "$TRAVIS_OS_NAME" != "osx" || brew install ccache meson
+
+  # Work around "Target /usr/local/lib/libgtest.a is a symlink
+  # belonging to nss. You can unlink it" during gtest install
+  - test "$TRAVIS_OS_NAME" != "osx" || brew unlink nss
+
   - test "$TRAVIS_OS_NAME" != "osx" || brew install --HEAD https://gist.githubusercontent.com/Kronuz/96ac10fbd8472eb1e7566d740c4034f8/raw/gtest.rb
 
 before_script:
diff --git a/NEWS b/NEWS
index 9c8ec529f..0b7be51e1 100644
--- a/NEWS
+++ b/NEWS
@@ -30,6 +30,16 @@ ver 0.22 (not yet released)
 * switch to C++17
   - GCC 7 or clang 4 (or newer) recommended
 
+ver 0.21.18 (2019/12/24)
+* protocol
+  - work around Mac OS X bug in the ISO 8601 parser
+* output
+  - alsa: fix hang bug with ALSA "null" outputs
+* storage
+  - curl: fix crash bug
+* drop support for CURL versions older than 7.32.0
+* reduce unnecessary CPU wakeups
+
 ver 0.21.17 (2019/12/16)
 * protocol
   - relax the ISO 8601 parser: allow omitting field separators, the
diff --git a/android/AndroidManifest.xml b/android/AndroidManifest.xml
index 80fa55394..5b9f57b60 100644
--- a/android/AndroidManifest.xml
+++ b/android/AndroidManifest.xml
@@ -2,8 +2,8 @@
 <manifest xmlns:android="http://schemas.android.com/apk/res/android"
           package="org.musicpd"
           android:installLocation="auto"
-          android:versionCode="40"
-          android:versionName="0.21.17">
+          android:versionCode="41"
+          android:versionName="0.21.18">
 
   <uses-sdk android:minSdkVersion="21" android:targetSdkVersion="26"/>
 
diff --git a/src/event/Loop.cxx b/src/event/Loop.cxx
index 75e2e0ae2..164b2905d 100644
--- a/src/event/Loop.cxx
+++ b/src/event/Loop.cxx
@@ -137,7 +137,8 @@ static constexpr int
 ExportTimeoutMS(std::chrono::steady_clock::duration timeout)
 {
 	return timeout >= timeout.zero()
-		? int(std::chrono::duration_cast<std::chrono::milliseconds>(timeout).count())
+		/* round up (+1) to avoid unnecessary wakeups */
+		? int(std::chrono::duration_cast<std::chrono::milliseconds>(timeout).count()) + 1
 		: -1;
 }
 
@@ -220,7 +221,6 @@ EventLoop::Run() noexcept
 	} while (!quit);
 
 #ifndef NDEBUG
-	assert(busy);
 	assert(thread.IsInside());
 #endif
 }
diff --git a/src/event/MultiSocketMonitor.cxx b/src/event/MultiSocketMonitor.cxx
index 3076ab4d6..4a9d0d33d 100644
--- a/src/event/MultiSocketMonitor.cxx
+++ b/src/event/MultiSocketMonitor.cxx
@@ -22,6 +22,10 @@
 
 #include <algorithm>
 
+#ifdef USE_EPOLL
+#include <errno.h>
+#endif
+
 #ifndef _WIN32
 #include <poll.h>
 #endif
@@ -37,17 +41,42 @@ MultiSocketMonitor::Reset() noexcept
 	assert(GetEventLoop().IsInside());
 
 	fds.clear();
+#ifdef USE_EPOLL
+	always_ready_fds.clear();
+#endif
 	IdleMonitor::Cancel();
 	timeout_event.Cancel();
 	ready = refresh = false;
 }
 
+bool
+MultiSocketMonitor::AddSocket(SocketDescriptor fd, unsigned events) noexcept
+{
+	fds.emplace_front(*this, fd);
+	bool success = fds.front().Schedule(events);
+	if (!success) {
+		fds.pop_front();
+
+#ifdef USE_EPOLL
+		if (errno == EPERM)
+			/* not supported by epoll (e.g. "/dev/null"):
+			   add it to the "always ready" list */
+			always_ready_fds.push_front({fd, events});
+#endif
+	}
+
+	return success;
+}
+
 void
 MultiSocketMonitor::ClearSocketList() noexcept
 {
 	assert(GetEventLoop().IsInside());
 
 	fds.clear();
+#ifdef USE_EPOLL
+	always_ready_fds.clear();
+#endif
 }
 
 #ifndef _WIN32
@@ -55,6 +84,10 @@ MultiSocketMonitor::ClearSocketList() noexcept
 void
 MultiSocketMonitor::ReplaceSocketList(pollfd *pfds, unsigned n) noexcept
 {
+#ifdef USE_EPOLL
+	always_ready_fds.clear();
+#endif
+
 	pollfd *const end = pfds + n;
 
 	UpdateSocketList([pfds, end](SocketDescriptor fd) -> unsigned {
@@ -64,9 +97,7 @@ MultiSocketMonitor::ReplaceSocketList(pollfd *pfds, unsigned n) noexcept
 			if (i == end)
 				return 0;
 
-			auto events = i->events;
-			i->events = 0;
-			return events;
+			return std::exchange(i->events, 0);
 		});
 
 	for (auto i = pfds; i != end; ++i)
@@ -79,7 +110,20 @@ MultiSocketMonitor::ReplaceSocketList(pollfd *pfds, unsigned n) noexcept
 void
 MultiSocketMonitor::Prepare() noexcept
 {
-	const auto timeout = PrepareSockets();
+	auto timeout = PrepareSockets();
+
+#ifdef USE_EPOLL
+	if (!always_ready_fds.empty()) {
+		/* if there was at least one file descriptor not
+		   supported by epoll, install a very short timeout
+		   because we assume it's always ready */
+		constexpr std::chrono::steady_clock::duration ready_timeout =
+			std::chrono::milliseconds(1);
+		if (timeout < timeout.zero() || timeout > ready_timeout)
+			timeout = ready_timeout;
+	}
+#endif
+
 	if (timeout >= timeout.zero())
 		timeout_event.Schedule(timeout);
 	else
diff --git a/src/event/MultiSocketMonitor.hxx b/src/event/MultiSocketMonitor.hxx
index d8f4ac635..2db5d1db8 100644
--- a/src/event/MultiSocketMonitor.hxx
+++ b/src/event/MultiSocketMonitor.hxx
@@ -49,12 +49,10 @@ class MultiSocketMonitor : IdleMonitor
 		unsigned revents;
 
 	public:
-		SingleFD(MultiSocketMonitor &_multi, SocketDescriptor _fd,
-			 unsigned events) noexcept
+		SingleFD(MultiSocketMonitor &_multi,
+			 SocketDescriptor _fd) noexcept
 			:SocketMonitor(_fd, _multi.GetEventLoop()),
-			multi(_multi), revents(0) {
-			Schedule(events);
-		}
+			multi(_multi), revents(0) {}
 
 		SocketDescriptor GetSocket() const noexcept {
 			return SocketMonitor::GetSocket();
@@ -85,8 +83,6 @@ class MultiSocketMonitor : IdleMonitor
 		}
 	};
 
-	friend class SingleFD;
-
 	TimerEvent timeout_event;
 
 	/**
@@ -105,6 +101,21 @@ class MultiSocketMonitor : IdleMonitor
 
 	std::forward_list<SingleFD> fds;
 
+#ifdef USE_EPOLL
+	struct AlwaysReady {
+		const SocketDescriptor fd;
+		const unsigned revents;
+	};
+
+	/**
+	 * A list of file descriptors which are always ready.  This is
+	 * a kludge needed because the ALSA output plugin gives us a
+	 * file descriptor to /dev/null, which is incompatible with
+	 * epoll (epoll_ctl() returns -EPERM).
+	 */
+	std::forward_list<AlwaysReady> always_ready_fds;
+#endif
+
 public:
 	static constexpr unsigned READ = SocketMonitor::READ;
 	static constexpr unsigned WRITE = SocketMonitor::WRITE;
@@ -146,9 +157,7 @@ public:
 	 *
 	 * May only be called from PrepareSockets().
 	 */
-	void AddSocket(SocketDescriptor fd, unsigned events) noexcept {
-		fds.emplace_front(*this, fd, events);
-	}
+	bool AddSocket(SocketDescriptor fd, unsigned events) noexcept;
 
 	/**
 	 * Remove all sockets.
@@ -203,6 +212,11 @@ public:
 				i.ClearReturnedEvents();
 			}
 		}
+
+#ifdef USE_EPOLL
+		for (const auto &i : always_ready_fds)
+			f(i.fd, i.revents);
+#endif
 	}
 
 protected:
@@ -231,7 +245,6 @@ private:
 
 	void OnTimeout() noexcept {
 		SetReady();
-		IdleMonitor::Schedule();
 	}
 
 	virtual void OnIdle() noexcept final;
diff --git a/src/event/SocketMonitor.cxx b/src/event/SocketMonitor.cxx
index 76f550094..f62e81404 100644
--- a/src/event/SocketMonitor.cxx
+++ b/src/event/SocketMonitor.cxx
@@ -64,20 +64,24 @@ SocketMonitor::Close() noexcept
 	Steal().Close();
 }
 
-void
+bool
 SocketMonitor::Schedule(unsigned flags) noexcept
 {
 	assert(IsDefined());
 
 	if (flags == GetScheduledFlags())
-		return;
+		return true;
 
+	bool success;
 	if (scheduled_flags == 0)
-		loop.AddFD(fd.Get(), flags, *this);
+		success = loop.AddFD(fd.Get(), flags, *this);
 	else if (flags == 0)
-		loop.RemoveFD(fd.Get(), *this);
+		success = loop.RemoveFD(fd.Get(), *this);
 	else
-		loop.ModifyFD(fd.Get(), flags, *this);
+		success = loop.ModifyFD(fd.Get(), flags, *this);
 
-	scheduled_flags = flags;
+	if (success)
+		scheduled_flags = flags;
+
+	return success;
 }
diff --git a/src/event/SocketMonitor.hxx b/src/event/SocketMonitor.hxx
index 4f4aa82c3..3804bf4ab 100644
--- a/src/event/SocketMonitor.hxx
+++ b/src/event/SocketMonitor.hxx
@@ -98,18 +98,22 @@ public:
 		return scheduled_flags;
 	}
 
-	void Schedule(unsigned flags) noexcept;
+	/**
+	 * @return true on success, false on error (with errno set if
+	 * USE_EPOLL is defined)
+	 */
+	bool Schedule(unsigned flags) noexcept;
 
 	void Cancel() noexcept {
 		Schedule(0);
 	}
 
-	void ScheduleRead() noexcept {
-		Schedule(GetScheduledFlags() | READ | HANGUP | ERROR);
+	bool ScheduleRead() noexcept {
+		return Schedule(GetScheduledFlags() | READ | HANGUP | ERROR);
 	}
 
-	void ScheduleWrite() noexcept {
-		Schedule(GetScheduledFlags() | WRITE);
+	bool ScheduleWrite() noexcept {
+		return Schedule(GetScheduledFlags() | WRITE);
 	}
 
 	void CancelRead() noexcept {
diff --git a/src/input/plugins/CurlInputPlugin.cxx b/src/input/plugins/CurlInputPlugin.cxx
index f4f88a13b..8d6c0d9d3 100644
--- a/src/input/plugins/CurlInputPlugin.cxx
+++ b/src/input/plugins/CurlInputPlugin.cxx
@@ -180,7 +180,6 @@ CurlInputStream::FreeEasyIndirect() noexcept
 {
 	BlockingCall(GetEventLoop(), [this](){
 			FreeEasy();
-			(*curl_init)->InvalidateSockets();
 		});
 }
 
diff --git a/src/lib/curl/Global.cxx b/src/lib/curl/Global.cxx
index 1e978bf7f..c83df1ad4 100644
--- a/src/lib/curl/Global.cxx
+++ b/src/lib/curl/Global.cxx
@@ -162,7 +162,6 @@ CurlGlobal::Remove(CurlRequest &r) noexcept
 	assert(GetEventLoop().IsInside());
 
 	curl_multi_remove_handle(multi.Get(), r.Get());
-	InvalidateSockets();
 }
 
 /**
@@ -220,12 +219,12 @@ CurlGlobal::UpdateTimeout(long timeout_ms) noexcept
 		return;
 	}
 
-	if (timeout_ms < 10)
-		/* CURL 7.21.1 likes to report "timeout=0", which
+	if (timeout_ms < 1)
+		/* CURL's threaded resolver sets a timeout of 0ms, which
 		   means we're running in a busy loop.  Quite a bad
 		   idea to waste so much CPU.  Let's use a lower limit
-		   of 10ms. */
-		timeout_ms = 10;
+		   of 1ms. */
+		timeout_ms = 1;
 
 	timeout_event.Schedule(std::chrono::milliseconds(timeout_ms));
 }
diff --git a/src/lib/curl/Global.hxx b/src/lib/curl/Global.hxx
index ccc180bcf..95065e9da 100644
--- a/src/lib/curl/Global.hxx
+++ b/src/lib/curl/Global.hxx
@@ -67,16 +67,6 @@ public:
 		SocketAction(CURL_SOCKET_TIMEOUT, 0);
 	}
 
-	/**
-	 * This is a kludge to allow pausing/resuming a stream with
-	 * libcurl < 7.32.0.  Read the curl_easy_pause manpage for
-	 * more information.
-	 */
-	void ResumeSockets() {
-		int running_handles;
-		curl_multi_socket_all(multi.Get(), &running_handles);
-	}
-
 private:
 	/**
 	 * Check for finished HTTP responses.
diff --git a/src/lib/curl/Request.cxx b/src/lib/curl/Request.cxx
index 8e576cd24..9126ee5c7 100644
--- a/src/lib/curl/Request.cxx
+++ b/src/lib/curl/Request.cxx
@@ -30,7 +30,6 @@
 #include "config.h"
 #include "Request.hxx"
 #include "Global.hxx"
-#include "Version.hxx"
 #include "Handler.hxx"
 #include "event/Call.hxx"
 #include "util/RuntimeError.hxx"
@@ -122,12 +121,6 @@ CurlRequest::Resume() noexcept
 
 	easy.Unpause();
 
-	if (IsCurlOlderThan(0x072000))
-		/* libcurl older than 7.32.0 does not update
-		   its sockets after curl_easy_pause(); force
-		   libcurl to do it now */
-		global.ResumeSockets();
-
 	global.InvalidateSockets();
 }
 
diff --git a/src/player/Thread.cxx b/src/player/Thread.cxx
index c09a00eca..0e982f63f 100644
--- a/src/player/Thread.cxx
+++ b/src/player/Thread.cxx
@@ -46,6 +46,7 @@
 #include "CrossFade.hxx"
 #include "tag/Tag.hxx"
 #include "Idle.hxx"
+#include "util/Compiler.h"
 #include "util/Domain.hxx"
 #include "thread/Name.hxx"
 #include "Log.hxx"
@@ -1175,6 +1176,7 @@ try {
 			}
 
 			/* fall through */
+			gcc_fallthrough;
 
 		case PlayerCommand::PAUSE:
 			next_song.reset();
diff --git a/src/storage/plugins/CurlStorage.cxx b/src/storage/plugins/CurlStorage.cxx
index 72ad439f4..af43eb876 100644
--- a/src/storage/plugins/CurlStorage.cxx
+++ b/src/storage/plugins/CurlStorage.cxx
@@ -105,7 +105,9 @@ public:
 			     BIND_THIS_METHOD(OnDeferredStart)),
 		 request(curl, uri, *this) {
 		// TODO: use CurlInputStream's configuration
+	}
 
+	void DeferStart() noexcept {
 		/* start the transfer inside the IOThread */
 		defer_start.Schedule();
 	}
@@ -278,6 +280,7 @@ public:
 	}
 
 	using BlockingHttpRequest::GetEasy;
+	using BlockingHttpRequest::DeferStart;
 	using BlockingHttpRequest::Wait;
 
 protected:
@@ -425,6 +428,7 @@ public:
 	}
 
 	const StorageFileInfo &Perform() {
+		DeferStart();
 		Wait();
 		return info;
 	}
@@ -476,6 +480,7 @@ public:
 		 base_path(UriPathOrSlash(uri)) {}
 
 	std::unique_ptr<StorageDirectoryReader> Perform() {
+		DeferStart();
 		Wait();
 		return ToReader();
 	}
diff --git a/src/time/ISO8601.cxx b/src/time/ISO8601.cxx
index 5cb4c486c..725215ea6 100644
--- a/src/time/ISO8601.cxx
+++ b/src/time/ISO8601.cxx
@@ -58,6 +58,8 @@ FormatISO8601(std::chrono::system_clock::time_point tp)
 	return FormatISO8601(GmTime(tp));
 }
 
+#ifndef _WIN32
+
 static std::pair<unsigned, unsigned>
 ParseTimeZoneOffsetRaw(const char *&s)
 {
@@ -108,6 +110,67 @@ ParseTimeZoneOffset(const char *&s)
 	return d;
 }
 
+static const char *
+ParseTimeOfDay(const char *s, struct tm &tm,
+	       std::chrono::system_clock::duration &precision) noexcept
+{
+	/* this function always checks "end==s" to work around a
+	   strptime() bug on OS X: if nothing could be parsed,
+	   strptime() returns the input string (indicating success)
+	   instead of nullptr (indicating error) */
+
+	const char *end = strptime(s, "%H", &tm);
+	if (end == nullptr || end == s)
+		return end;
+
+	s = end;
+	precision = std::chrono::hours(1);
+
+	if (*s == ':') {
+		/* with field separators: now a minute must follow */
+
+		++s;
+
+		end = strptime(s, "%M", &tm);
+		if (end == nullptr || end == s)
+			return nullptr;
+
+		s = end;
+		precision = std::chrono::minutes(1);
+
+		/* the "seconds" field is optional */
+		if (*s != ':')
+			return s;
+
+		++s;
+
+		end = strptime(s, "%S", &tm);
+		if (end == nullptr || end == s)
+			return nullptr;
+
+		precision = std::chrono::seconds(1);
+		return end;
+	}
+
+	/* without field separators */
+
+	end = strptime(s, "%M", &tm);
+	if (end == nullptr || end == s)
+		return s;
+
+	s = end;
+	precision = std::chrono::minutes(1);
+
+	end = strptime(s, "%S", &tm);
+	if (end == nullptr || end == s)
+		return s;
+
+	precision = std::chrono::seconds(1);
+	return end;
+}
+
+#endif
+
 std::pair<std::chrono::system_clock::time_point,
 	  std::chrono::system_clock::duration>
 ParseISO8601(const char *s)
@@ -138,22 +201,9 @@ ParseISO8601(const char *s)
 	if (*s == 'T') {
 		++s;
 
-		if ((end = strptime(s, "%T", &tm)) != nullptr)
-			precision = std::chrono::seconds(1);
-		else if ((end = strptime(s, "%H%M%S", &tm)) != nullptr)
-			/* no field separators */
-			precision = std::chrono::seconds(1);
-		else if ((end = strptime(s, "%H%M", &tm)) != nullptr)
-			/* no field separators */
-			precision = std::chrono::minutes(1);
-		else if ((end = strptime(s, "%H:%M", &tm)) != nullptr)
-			precision = std::chrono::minutes(1);
-		else if ((end = strptime(s, "%H", &tm)) != nullptr)
-			precision = std::chrono::hours(1);
-		else
+		s = ParseTimeOfDay(s, tm, precision);
+		if (s == nullptr)
 			throw std::runtime_error("Failed to parse time of day");
-
-		s = end;
 	}
 
 	auto tp = TimeGm(tm);
diff --git a/src/util/Compiler.h b/src/util/Compiler.h
index e441d3835..57a464a6e 100644
--- a/src/util/Compiler.h
+++ b/src/util/Compiler.h
@@ -143,6 +143,14 @@
 #define gcc_flatten
 #endif
 
+#if GCC_CHECK_VERSION(7,0)
+#define gcc_fallthrough __attribute__((fallthrough))
+#elif CLANG_CHECK_VERSION(10,0)
+#define gcc_fallthrough [[fallthrough]]
+#else
+#define gcc_fallthrough
+#endif
+
 #ifndef __cplusplus
 /* plain C99 has "restrict" */
 #define gcc_restrict restrict
diff --git a/src/util/format.c b/src/util/format.c
index 1906e0d33..774e8a2e9 100644
--- a/src/util/format.c
+++ b/src/util/format.c
@@ -19,6 +19,7 @@
  */
 
 #include "format.h"
+#include "util/Compiler.h"
 
 #include <stdbool.h>
 #include <stdio.h>
@@ -238,6 +239,7 @@ format_object2(const char *format, const char **last, const void *object,
 			}
 
 			/* fall through */
+			gcc_fallthrough;
 
 		default:
 			/* pass-through non-escaped portions of the format string */
diff --git a/test/RunCurl.cxx b/test/RunCurl.cxx
new file mode 100644
index 000000000..c8ca25909
--- /dev/null
+++ b/test/RunCurl.cxx
@@ -0,0 +1,93 @@
+/*
+ * Copyright 2003-2019 The Music Player Daemon Project
+ * http://www.musicpd.org
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+ */
+
+#include "ShutdownHandler.hxx"
+#include "lib/curl/Global.hxx"
+#include "lib/curl/Request.hxx"
+#include "lib/curl/Handler.hxx"
+#include "event/Loop.hxx"
+#include "util/PrintException.hxx"
+
+#include <stdio.h>
+
+class MyHandler final : public CurlResponseHandler {
+	EventLoop &event_loop;
+
+	std::exception_ptr error;
+
+public:
+	explicit MyHandler(EventLoop &_event_loop) noexcept
+		:event_loop(_event_loop) {}
+
+	void Finish() {
+		if (error)
+			std::rethrow_exception(error);
+	}
+
+	/* virtual methods from CurlResponseHandler */
+	void OnHeaders(unsigned status,
+		       std::multimap<std::string, std::string> &&headers) override {
+		fprintf(stderr, "status: %u\n", status);
+		for (const auto &i : headers)
+			fprintf(stderr, "%s: %s\n",
+				i.first.c_str(), i.second.c_str());
+	}
+
+	void OnData(ConstBuffer<void> data) override {
+		if (fwrite(data.data, data.size, 1, stdout) != 1)
+			throw std::runtime_error("Failed to write");
+	}
+
+	void OnEnd() override {
+		event_loop.Break();
+	}
+
+	void OnError(std::exception_ptr e) noexcept override {
+		error = std::move(e);
+		event_loop.Break();
+	}
+};
+
+int
+main(int argc, char **argv) noexcept
+try {
+	if (argc != 2) {
+		fprintf(stderr, "Usage: RunCurl URI\n");
+		return EXIT_FAILURE;
+	}
+
+	const char *const uri = argv[1];
+
+	EventLoop event_loop;
+	const ShutdownHandler shutdown_handler(event_loop);
+	CurlGlobal curl_global(event_loop);
+
+	MyHandler handler(event_loop);
+	CurlRequest request(curl_global, uri, handler);
+	request.Start();
+
+	event_loop.Run();
+
+	handler.Finish();
+
+	return EXIT_SUCCESS;
+} catch (...) {
+	PrintException(std::current_exception());
+	return EXIT_FAILURE;
+}
diff --git a/test/meson.build b/test/meson.build
index bdd6d82c7..db78ada48 100644
--- a/test/meson.build
+++ b/test/meson.build
@@ -342,6 +342,18 @@ executable(
 )
 
 if curl_dep.found()
+  executable(
+    'RunCurl',
+    'RunCurl.cxx',
+    'ShutdownHandler.cxx',
+    '../src/Log.cxx',
+    '../src/LogBackend.cxx',
+    include_directories: inc,
+    dependencies: [
+      curl_dep,
+    ],
+  )
+
   test('test_icy_parser', executable(
     'test_icy_parser',
     'test_icy_parser.cxx',
diff --git a/test/run_storage.cxx b/test/run_storage.cxx
index 67816d8f8..b27b1bbe6 100644
--- a/test/run_storage.cxx
+++ b/test/run_storage.cxx
@@ -90,6 +90,29 @@ Ls(Storage &storage, const char *path)
 	return EXIT_SUCCESS;
 }
 
+static int
+Stat(Storage &storage, const char *path)
+{
+	const auto info = storage.GetInfo(path, false);
+	switch (info.type) {
+	case StorageFileInfo::Type::OTHER:
+		printf("other\n");
+		break;
+
+	case StorageFileInfo::Type::REGULAR:
+		printf("regular\n");
+		break;
+
+	case StorageFileInfo::Type::DIRECTORY:
+		printf("directory\n");
+		break;
+	}
+
+	printf("size: %llu\n", (unsigned long long)info.size);
+
+	return EXIT_SUCCESS;
+}
+
 int
 main(int argc, char **argv)
 try {
@@ -117,6 +140,18 @@ try {
 					   storage_uri);
 
 		return Ls(*storage, path);
+	} else if (strcmp(command, "stat") == 0) {
+		if (argc != 4) {
+			fprintf(stderr, "Usage: run_storage stat URI PATH\n");
+			return EXIT_FAILURE;
+		}
+
+		const char *const path = argv[3];
+
+		auto storage = MakeStorage(io_thread.GetEventLoop(),
+					   storage_uri);
+
+		return Stat(*storage, path);
 	} else {
 		fprintf(stderr, "Unknown command\n");
 		return EXIT_FAILURE;