mirror of
https://github.com/koreader/koreader.git
synced 2026-10-30 12:15:07 +01:00
See <https://github.com/koreader/koreader/pull/15877#issuecomment-5868829847>.
394 lines
14 KiB
Lua
394 lines
14 KiB
Lua
--[[--
|
|
Non-blocking HTTP(S) downloads and a coroutine-based concurrent scheduler.
|
|
|
|
Unlike `httpclient.lua` (Turbo's I/O loop), this works without Turbo: it
|
|
drives LuaSocket/LuaSec sockets in non-blocking mode, yielding to a
|
|
`socket.select`-based scheduler so one Lua state can interleave many
|
|
downloads.
|
|
|
|
Sources:
|
|
- The coroutine scheduler is based on <https://www.lua.org/pil/9.4.html>
|
|
- The HTTP request/response handling (request lines, header parsing, redirects, chunked and content-length body reading) is adapted from LuaSocket (http.lua / the LTN12/http client).
|
|
]]
|
|
|
|
local logger = require("logger")
|
|
local socket = require("socket")
|
|
local socket_url = require("socket.url")
|
|
local ssl = require("ssl")
|
|
local socketutil = require("socketutil")
|
|
local util = require("util")
|
|
|
|
local HttpAsync = {}
|
|
|
|
local MAX_REDIRECTS = 5
|
|
local DEFAULT_CONCURRENCY = 10
|
|
|
|
-- Yielded by a fetch coroutine to signal completion (followed by item, success, content). Any other first yield value is a socket + "r"/"w" mode.
|
|
local DONE = "DONE"
|
|
|
|
--- Non-blocking receive; yields (socket, "r"/"w") until data/error/EOF.
|
|
-- LuaSocket's receive(pattern, prefix) prepends `prefix` to the buffer it
|
|
-- reads into, so any partial data already consumed from the socket on a
|
|
-- previous (timed-out) call must be passed back as the prefix on the next
|
|
-- call. We accumulate that partial data in `accumulated` and always prepend
|
|
-- it, so a resumed receive continues exactly where it left off instead of
|
|
-- discarding bytes already pulled from the socket.
|
|
local function async_receive(sock, pattern)
|
|
local accumulated = ""
|
|
while true do
|
|
local res, err, partial = sock:receive(pattern, accumulated)
|
|
if err == "timeout" or err == "wantread" then
|
|
-- Carry forward the partial data already consumed from the socket.
|
|
accumulated = partial or accumulated
|
|
coroutine.yield(sock, "r")
|
|
elseif err == "wantwrite" then
|
|
coroutine.yield(sock, "w")
|
|
else
|
|
return res, err, partial
|
|
end
|
|
end
|
|
end
|
|
|
|
--- Non-blocking send; yields (socket, "w"/"r") while it would block.
|
|
local function async_send(sock, data)
|
|
local i = 1
|
|
local n = #data
|
|
while i <= n do
|
|
local res, err, last = sock:send(data, i)
|
|
if err == "timeout" or err == "wantwrite" then
|
|
coroutine.yield(sock, "w")
|
|
-- LuaSocket reports the index of the last byte sent on error.
|
|
if last then i = last + 1 end
|
|
elseif err == "wantread" then
|
|
coroutine.yield(sock, "r")
|
|
elseif err then
|
|
return nil, err
|
|
else
|
|
i = res + 1
|
|
end
|
|
end
|
|
return true
|
|
end
|
|
|
|
--- Non-blocking connect; yields (socket, "w") until connected or failed.
|
|
local function async_connect(sock, host, port)
|
|
sock:settimeout(0)
|
|
while true do
|
|
local res, err = sock:connect(host, port)
|
|
if res or err == "already connected" then
|
|
return true
|
|
elseif err == "timeout" or err == "Operation already in progress" then
|
|
coroutine.yield(sock, "w")
|
|
else
|
|
return false, err
|
|
end
|
|
end
|
|
end
|
|
|
|
--- Non-blocking TLS handshake; yields (socket, "r"/"w") while it would block.
|
|
local function async_handshake(sock)
|
|
while true do
|
|
local res, err = sock:dohandshake()
|
|
if res then
|
|
return true
|
|
elseif err == "wantread" then
|
|
coroutine.yield(sock, "r")
|
|
elseif err == "wantwrite" then
|
|
coroutine.yield(sock, "w")
|
|
else
|
|
return false, err
|
|
end
|
|
end
|
|
end
|
|
|
|
--- Fetch one URL over a raw non-blocking socket, yielding while it would
|
|
-- block so the caller can interleave many downloads.
|
|
-- @param url string
|
|
-- @param redirect_count number internal recursion guard
|
|
-- @param headers table optional extra request headers (e.g. cookie)
|
|
-- @return (true, content, content_type) or (false, err)
|
|
function HttpAsync.fetch_url(url, redirect_count, headers)
|
|
redirect_count = redirect_count or 0
|
|
if redirect_count > MAX_REDIRECTS then return false, "Too many redirects" end
|
|
|
|
local parsed = socket_url.parse(url)
|
|
if not parsed then return false, "invalid url" end
|
|
if parsed.path then
|
|
-- Encode invalid path chars (e.g., spaces) while preserving "/" and "%".
|
|
parsed.path = util.urlEncode(parsed.path, "/%%")
|
|
url = socket_url.build(parsed)
|
|
end
|
|
|
|
local host = parsed.host
|
|
if not host then return false, "invalid url" end
|
|
local port = parsed.port or (parsed.scheme == "https" and 443 or 80)
|
|
local path = parsed.path or "/"
|
|
if parsed.query then path = path .. "?" .. parsed.query end
|
|
|
|
local sock = socket.tcp()
|
|
local ok, err = async_connect(sock, host, port)
|
|
if not ok then
|
|
sock:close()
|
|
return false, "connect error: " .. tostring(err)
|
|
end
|
|
|
|
if parsed.scheme == "https" then
|
|
local ssl_params = {
|
|
mode = "client",
|
|
protocol = "any",
|
|
verify = "none",
|
|
options = {"all", "no_sslv2", "no_sslv3"},
|
|
}
|
|
local ssl_sock, wrap_err = ssl.wrap(sock, ssl_params)
|
|
if not ssl_sock then
|
|
sock:close()
|
|
return false, "ssl wrap error: " .. tostring(wrap_err)
|
|
end
|
|
sock = ssl_sock
|
|
sock:settimeout(0)
|
|
if sock.sni then
|
|
sock:sni(host)
|
|
end
|
|
local hok, herr = async_handshake(sock)
|
|
if not hok then
|
|
sock:close()
|
|
return false, "ssl handshake error: " .. tostring(herr)
|
|
end
|
|
end
|
|
|
|
local req_lines = {
|
|
string.format("GET %s HTTP/1.1", path),
|
|
string.format("Host: %s", host),
|
|
string.format("User-Agent: %s", socketutil.USER_AGENT),
|
|
}
|
|
if headers then
|
|
for k, v in pairs(headers) do
|
|
table.insert(req_lines, string.format("%s: %s", k, v))
|
|
end
|
|
end
|
|
table.insert(req_lines, "Connection: close")
|
|
table.insert(req_lines, "")
|
|
table.insert(req_lines, "")
|
|
local req = table.concat(req_lines, "\r\n")
|
|
|
|
local sok, serr = async_send(sock, req)
|
|
if not sok then
|
|
sock:close()
|
|
return false, "send error: " .. tostring(serr)
|
|
end
|
|
|
|
-- Read status line and headers.
|
|
local resp_headers = {}
|
|
local status_line
|
|
while true do
|
|
local line, lerr = async_receive(sock, "*l")
|
|
if not line then
|
|
sock:close()
|
|
return false, "read error: " .. tostring(lerr)
|
|
end
|
|
if line == "" then break end
|
|
if not status_line then
|
|
status_line = line
|
|
else
|
|
local k, v = line:match("^(.-):%s*(.*)")
|
|
if k and v then resp_headers[k:lower()] = v end
|
|
end
|
|
end
|
|
|
|
if not status_line then
|
|
sock:close()
|
|
return false, "no status line received"
|
|
end
|
|
|
|
local code = tonumber(status_line:match("HTTP/%d%.%d%s+(%d%d%d)"))
|
|
|
|
if code and code >= 300 and code < 400 and resp_headers["location"] then
|
|
sock:close()
|
|
local location = resp_headers["location"]:gsub("\r$", "")
|
|
local new_url = socket_url.absolute(url, location)
|
|
return HttpAsync.fetch_url(new_url, redirect_count + 1, headers)
|
|
end
|
|
|
|
if code and code >= 400 then
|
|
sock:close()
|
|
return false, "HTTP error " .. tostring(code)
|
|
end
|
|
|
|
-- Read the body, handling both chunked and fixed/until-close encodings.
|
|
local body = {}
|
|
local transfer_encoding = resp_headers["transfer-encoding"]
|
|
if transfer_encoding and transfer_encoding:lower():find("chunked", 1, true) then
|
|
while true do
|
|
local chunk_size_str = async_receive(sock, "*l")
|
|
if not chunk_size_str then break end
|
|
local hex = chunk_size_str:match("^%x+")
|
|
if not hex then break end
|
|
local chunk_size = tonumber(hex, 16)
|
|
if not chunk_size or chunk_size == 0 then break end
|
|
|
|
local chunk_data = async_receive(sock, chunk_size)
|
|
if chunk_data and #chunk_data > 0 then table.insert(body, chunk_data) end
|
|
async_receive(sock, 2) -- trailing CRLF
|
|
end
|
|
else
|
|
local content_length = tonumber(resp_headers["content-length"])
|
|
if content_length then
|
|
local remaining = content_length
|
|
while remaining > 0 do
|
|
local chunk = async_receive(sock, remaining)
|
|
if not chunk then break end
|
|
table.insert(body, chunk)
|
|
remaining = remaining - #chunk
|
|
end
|
|
else
|
|
while true do
|
|
local chunk, cerr, partial = async_receive(sock, 8192)
|
|
if chunk then
|
|
table.insert(body, chunk)
|
|
else
|
|
if partial and #partial > 0 then table.insert(body, partial) end
|
|
if cerr == "closed" then break end
|
|
if cerr ~= "timeout" and cerr ~= "wantread" then
|
|
-- Some other error: stop reading.
|
|
break
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
sock:close()
|
|
local content = table.concat(body)
|
|
return true, content, resp_headers["content-type"]
|
|
end
|
|
|
|
--- Download many tasks concurrently via a coroutine scheduler.
|
|
--
|
|
-- Each task is a caller-supplied value; `opts.get_url(task)` yields its URL.
|
|
-- The scheduler runs up to `opts.concurrency` fetches at once, resuming each
|
|
-- coroutine whenever `socket.select` says its socket is ready.
|
|
--
|
|
-- Yield contract: a fetch coroutine either yields a socket object together
|
|
-- with a "r"/"w" mode (meaning "I'm blocked waiting on this socket, resume me
|
|
-- when it's ready"), or yields the sentinel `DONE` followed by
|
|
-- (item, success, content) to signal completion. The scheduler never sees the
|
|
-- downloaded content as a yield value, so a response body cannot be mistaken
|
|
-- for the completion sentinel.
|
|
--
|
|
-- @param tasks list of arbitrary task values
|
|
-- @param opts table:
|
|
-- concurrency number max simultaneous fetches (default 10)
|
|
-- get_url(task) -> string (default: identity, i.e. tasks are URL strings)
|
|
-- fetch(url) -> (success, content) (default: HttpAsync.fetch_url)
|
|
-- on_success(task, content) called for each successful download
|
|
-- on_failure(task, err) called for each failed download
|
|
-- on_progress(completed, total) -> bool called periodically; return false to cancel
|
|
-- @return true if all tasks completed, false if cancelled
|
|
function HttpAsync.fetch_many(tasks, opts)
|
|
opts = opts or {}
|
|
local concurrency = opts.concurrency or DEFAULT_CONCURRENCY
|
|
local get_url = opts.get_url or function(task) return task end
|
|
local fetch = opts.fetch or function(url)
|
|
return HttpAsync.fetch_url(url)
|
|
end
|
|
local on_success = opts.on_success
|
|
local on_failure = opts.on_failure
|
|
local on_progress = opts.on_progress
|
|
|
|
local pending = {}
|
|
for i, task in ipairs(tasks) do
|
|
table.insert(pending, {task = task, index = i})
|
|
end
|
|
local total = #pending
|
|
|
|
local active = {} -- running coroutines
|
|
local co2sock = {} -- coroutine -> socket it's waiting on
|
|
local co2mode = {} -- coroutine -> "r" or "w"
|
|
local completed = 0
|
|
|
|
local function refill_pool()
|
|
while #active < concurrency and #pending > 0 do
|
|
local item = table.remove(pending, 1)
|
|
local co = coroutine.create(function()
|
|
local ok, content = fetch(get_url(item.task))
|
|
coroutine.yield(DONE, item, ok, content)
|
|
end)
|
|
table.insert(active, co)
|
|
end
|
|
end
|
|
|
|
refill_pool()
|
|
|
|
local cancelled = false
|
|
while #active > 0 and not cancelled do
|
|
-- Gather sockets we're waiting on and select() on them.
|
|
local recvt, sendt = {}, {}
|
|
for _, co in ipairs(active) do
|
|
local sock = co2sock[co]
|
|
if sock then
|
|
if co2mode[co] == "r" then table.insert(recvt, sock) end
|
|
if co2mode[co] == "w" then table.insert(sendt, sock) end
|
|
end
|
|
end
|
|
|
|
local ready
|
|
if #recvt > 0 or #sendt > 0 then
|
|
local r, w = socket.select(recvt, sendt, 0.1)
|
|
ready = {}
|
|
for _, s in ipairs(r or {}) do ready[s] = true end
|
|
for _, s in ipairs(w or {}) do ready[s] = true end
|
|
end
|
|
|
|
-- Resume coroutines that are ready (or haven't yielded a socket yet).
|
|
local next_active = {}
|
|
for _, co in ipairs(active) do
|
|
local sock = co2sock[co]
|
|
local is_ready = not sock or (ready and ready[sock])
|
|
|
|
if not is_ready then
|
|
table.insert(next_active, co)
|
|
else
|
|
if sock then
|
|
co2sock[co] = nil
|
|
co2mode[co] = nil
|
|
end
|
|
|
|
local ok, yield_type, yield_arg2, success, content = coroutine.resume(co)
|
|
|
|
if not ok then
|
|
logger.warn("httpasync coroutine error:", yield_type)
|
|
completed = completed + 1
|
|
elseif coroutine.status(co) == "dead" then
|
|
-- Finished without a DONE yield; treat as done.
|
|
completed = completed + 1
|
|
elseif yield_type == DONE then
|
|
completed = completed + 1
|
|
local item = yield_arg2
|
|
if success and content then
|
|
if on_success then on_success(item.task, content) end
|
|
else
|
|
if on_failure then on_failure(item.task, content) end
|
|
end
|
|
else
|
|
-- The coroutine yielded a socket and mode ("r"/"w").
|
|
co2sock[co] = yield_type
|
|
co2mode[co] = yield_arg2
|
|
table.insert(next_active, co)
|
|
end
|
|
end
|
|
end
|
|
active = next_active
|
|
|
|
refill_pool()
|
|
|
|
if on_progress and not on_progress(completed, total) then
|
|
cancelled = true
|
|
end
|
|
end
|
|
|
|
-- true = all tasks completed (success), false = cancelled by on_progress.
|
|
return not cancelled
|
|
end
|
|
|
|
return HttpAsync
|