diff --git a/docs/engine/front-end.md b/docs/engine/front-end.md index 240f4258..e2e12159 100644 --- a/docs/engine/front-end.md +++ b/docs/engine/front-end.md @@ -338,10 +338,10 @@ Each change is written to `starlancer.ini` at once, in the section `[OpenReliant - GET MODS in white, centred on (320, 95). - The framed list from (45, 136), with the catalogue's mods grouped by their top category, the first folder of their category path: a heading row for each group, in the large font and blue as the controls tab's headings are, with the group's name in capitals (`- SHIPS`), then the group's mods sorted by their full path and their name, indented by 14 pixels, each as its name and version from the index. A click on a heading folds the group, which hides its mods and turns the heading's `-` into a `+`; another click unfolds it. Mods without a category come last, under OTHER MODS. A mod that needs a newer OpenReliant is red; otherwise the selected mod is white, a mod installed in an older version is gold, and an installed mod is dimmed. While the repositories are read, the list says READING THE CATALOG. If none can be read, the list says CAN'T READ THE CATALOG and why, in red. If one of several can't be read, its name and the reason are in red above the list, ending at the panel's right edge where the mods screen's RESTART TO APPLY is, and the others' mods are listed. An empty catalogue says THE CATALOG OFFERS NO MODS. The screen lists at most 127 mods, since each mod and each group takes one of the list's 255 rows. -- The framed panel from (401, 136), which says CHOOSE A MOD while nothing is selected, and otherwise shows the selected mod's thumbnail, downloaded when the mod is selected and fitted into the same box as on the mods screen; its name; right under it, its status line, in gold: INSTALLED with the installed version, BY HAND where the settings file doesn't record the install, and UPDATE TO with the catalogue's version where it is newer, INSTALLED BY HAND AS A FOLDER: INSTALL WON'T REPLACE IT where a folder mod of the name is in the `mods` folder, DOWNLOADING with the percentage, or INSTALLED: RESTART TO APPLY; in red: NEEDS OPENRELIANT with the version, or why the install failed. Then its version and author; its full category, as IN SHIPS / FIGHTERS / ALLIANCE; the repository that lists it, as FROM openreliant-mods; the OpenReliant it needs, in red when it is newer than this one; its size and its description, which takes the lines left. During a download, a progress bar at the bottom of the panel, measured against the size the catalogue gives. -- The settings screen's buttons: OK and MAIN MENU in the left column. In the right column, INSTALL (or UPDATE when an older version is installed) where RESET DEFAULTS is, dimmed when there is nothing to do, and RELOAD where CANCEL CHANGES is, which reads the repositories again and tries again the thumbnails that failed. Both are dimmed while a mod downloads: one download runs at a time, and the catalogue can't change under it. OK and Escape go back to the mods screen, which rereads the `mods` folder if a mod was installed. +- The framed panel from (401, 136), which says CHOOSE A MOD while nothing is selected, and otherwise shows the selected mod's thumbnail, downloaded when the mod is selected and fitted into the same box as on the mods screen; its name; right under it, its status line, in gold: INSTALLED with the installed version, BY HAND where the settings file doesn't record the install, and UPDATE TO with the catalogue's version where it is newer, INSTALLED BY HAND AS A FOLDER: INSTALL WON'T REPLACE IT where a folder mod of the name is in the `mods` folder, DOWNLOADING with the percentage, CANCELING once CANCEL was pressed, or INSTALLED: RESTART TO APPLY; in red: NEEDS OPENRELIANT with the version, or why the install failed. Then its version and author; its full category, as IN SHIPS / FIGHTERS / ALLIANCE; the repository that lists it, as FROM openreliant-mods; the OpenReliant it needs, in red when it is newer than this one; its size and its description, which takes the lines left. During a download, a progress bar at the bottom of the panel, measured against the size the catalogue gives. +- The settings screen's buttons: OK and MAIN MENU in the left column. In the right column, INSTALL (or UPDATE when an older version is installed) where RESET DEFAULTS is, dimmed when there is nothing to do, and RELOAD where CANCEL CHANGES is, which reads the repositories again and tries again the thumbnails that failed. While a mod downloads, one download runs at a time and the catalogue can't change under it, so RELOAD is dimmed, and INSTALL reads CANCEL while the downloading mod is chosen and is dimmed while another is. CANCEL stops the download, which deletes its part file; the panel says CANCELING until the download's thread has stopped, then shows the mod as it was before. OK and Escape go back to the mods screen, which rereads the `mods` folder if a mod was installed. -The repositories `starlancer.ini` names ([Repositories](../guide/modding.md#repositories)) are read one after another into one catalogue, where the first repository to list an id wins, and the catalogue is kept until RELOAD or until the front end closes. A mod without the keys it needs (`id`, `archive`, `sha256`, `size`), whose id can't name a mod, or whose size is over 4294967295 bytes, the most a HOG archive's 32-bit offsets reach, is skipped with a line in the log, which escapes the id; an index in a newer format fails with THE CATALOG NEEDS A NEWER OPENRELIANT. Every download goes through [`web.zig`](../../src/engine/web.zig), on a thread of its own with its own HTTP client, and over HTTPS: `http` is changed to `https` before each request and each redirect, which the download follows itself, five at most; any other scheme is refused. Each request sends `OpenReliant` as its user agent. The player's own machine (`localhost` and the names under it, `127.0.0.0/8` and `0.0.0.0` in every form the system's resolver reads, such as `127.1`, and `::1`, `::` and the IPv4 addresses mapped into IPv6) is read only for a repository whose URL names it, and for the thumbnails and archives its index links, over plain HTTP. Anywhere else, a link to the player's machine, or a redirect to it from another host, fails with THE CATALOG POINTS AT THIS COMPUTER, so that a repository on the web can't make OpenReliant send requests to the services on the player's machine. A host name that the system's resolver turns into the player's own address isn't caught, since only the link itself is checked, but such a link is read over HTTPS, so a service there gets the start of a secure connection that it can't complete, never a request. A failure says what happened: the server's name can't be found, a secure connection can't be made, the system's certificates can't be loaded, too many redirects, a bad redirect, and so on. An index or a thumbnail gives up after 60 seconds, or after 15 seconds without anything arriving, and an archive after 30 seconds without anything arriving, with THE SERVER DOESN'T ANSWER. An index is read up to 4 MiB and a thumbnail up to 2 MiB; an archive is read up to the size the catalogue gives, and a download that goes past it, or that the server announces at another size, stops at once. INSTALL downloads the archive into `.hog.part` in the `mods` folder, hashing the bytes on their way to the file. Once the archive is complete and matches the catalogue's digest, the digest is written as `.hog.sha256.part`, the archive's part is renamed to `.hog`, replacing an older copy, and the checksum file's part is renamed to `.hog.sha256`; then the install is recorded in `[OpenReliantInstalledMods]` with the repository's name, so that the loader refuses the archive without its checksum file from then on. A download that fails or doesn't match leaves nothing behind; if the checksum file can't be put in place, the archive is removed again, with the checksum file of the copy it replaced. Leaving the screen lets a download continue. Closing the front end lets go of it without waiting, so that quitting is never held up, not even by a lookup of a host's address, which nothing can interrupt: the download's thread cancels it and ends on its own, deleting an archive's part file, unless OpenReliant has ended first, in which case the next install of the mod replaces the part file. Not yet: cancelling a download from the screen ([#1040](https://github.com/OpenReliant/openreliant/issues/1040)), and updates shown on the mods screen ([#1041](https://github.com/OpenReliant/openreliant/issues/1041)). The GET MODS button on the mods screen uses the settings screen's shapes, at (45, 116) with its label to its right, and is shown only when the settings file names a repository. +The repositories `starlancer.ini` names ([Repositories](../guide/modding.md#repositories)) are read one after another into one catalogue, where the first repository to list an id wins, and the catalogue is kept until RELOAD or until the front end closes. A mod without the keys it needs (`id`, `archive`, `sha256`, `size`), whose id can't name a mod, or whose size is over 4294967295 bytes, the most a HOG archive's 32-bit offsets reach, is skipped with a line in the log, which escapes the id; an index in a newer format fails with THE CATALOG NEEDS A NEWER OPENRELIANT. Every download goes through [`web.zig`](../../src/engine/web.zig), on a thread of its own with its own HTTP client, and over HTTPS: `http` is changed to `https` before each request and each redirect, which the download follows itself, five at most; any other scheme is refused. Each request sends `OpenReliant` as its user agent. The player's own machine (`localhost` and the names under it, `127.0.0.0/8` and `0.0.0.0` in every form the system's resolver reads, such as `127.1`, and `::1`, `::` and the IPv4 addresses mapped into IPv6) is read only for a repository whose URL names it, and for the thumbnails and archives its index links, over plain HTTP. Anywhere else, a link to the player's machine, or a redirect to it from another host, fails with THE CATALOG POINTS AT THIS COMPUTER, so that a repository on the web can't make OpenReliant send requests to the services on the player's machine. A host name that the system's resolver turns into the player's own address isn't caught, since only the link itself is checked, but such a link is read over HTTPS, so a service there gets the start of a secure connection that it can't complete, never a request. A failure says what happened: the server's name can't be found, a secure connection can't be made, the system's certificates can't be loaded, too many redirects, a bad redirect, and so on. An index or a thumbnail gives up after 60 seconds, or after 15 seconds without anything arriving, and an archive after 30 seconds without anything arriving, with THE SERVER DOESN'T ANSWER. An index is read up to 4 MiB and a thumbnail up to 2 MiB; an archive is read up to the size the catalogue gives, and a download that goes past it, or that the server announces at another size, stops at once. INSTALL downloads the archive into `.hog.part` in the `mods` folder, hashing the bytes on their way to the file. Once the archive is complete and matches the catalogue's digest, the digest is written as `.hog.sha256.part`, the archive's part is renamed to `.hog`, replacing an older copy, and the checksum file's part is renamed to `.hog.sha256`; then the install is recorded in `[OpenReliantInstalledMods]` with the repository's name, so that the loader refuses the archive without its checksum file from then on. A download that fails or doesn't match leaves nothing behind; if the checksum file can't be put in place, the archive is removed again, with the checksum file of the copy it replaced. Leaving the screen lets a download continue. CANCEL stops it: its thread cancels the request, deletes the archive's part file and ends, and only then does the screen let the install go, so that an install of the same mod started right after can't run into the part file being deleted. Closing the front end lets go of a download without waiting, so that quitting is never held up, not even by a lookup of a host's address, which nothing can interrupt: the download's thread cancels it and ends on its own, deleting an archive's part file, unless OpenReliant has ended first, in which case the next install of the mod replaces the part file. Not yet: updates shown on the mods screen ([#1041](https://github.com/OpenReliant/openreliant/issues/1041)). The GET MODS button on the mods screen uses the settings screen's shapes, at (45, 116) with its label to its right, and is shown only when the settings file names a repository. ## The mod's options diff --git a/docs/guide/modding.md b/docs/guide/modding.md index eb3fad2d..2eb8920d 100644 --- a/docs/guide/modding.md +++ b/docs/guide/modding.md @@ -200,8 +200,10 @@ list, with the panel saying which version is installed and which one the update needs a newer OpenReliant is red and can't be installed. A mod you copied into the `mods` folder as a folder is left alone, and the panel says so, since OpenReliant would load both the folder and the archive. RELOAD reads the repositories again. One download runs at a time: INSTALL and RELOAD wait -for it. OK goes back to the mods screen, which now lists the installed mods. Like every change on -the mods screen, they take effect at the next start. +for it. While the mod you chose downloads, INSTALL reads CANCEL instead: it stops the download and +leaves nothing in the `mods` folder, and the panel says CANCELING for a moment while the download +stops. Leaving the screen lets a download go on. OK goes back to the mods screen, which now lists +the installed mods. Like every change on the mods screen, they take effect at the next start. The catalogue and the downloads run in the background, so the menus keep working, and the panel shows the download's progress. Besides the check for a newer release ([Newer @@ -217,9 +219,7 @@ CATALOG POINTS AT THIS COMPUTER. A server that stops answering fails with THE SE an index or a thumbnail gives up after a minute, or after 15 seconds with nothing arriving, and an archive after 30 seconds with nothing arriving. RELOAD then reads the repositories again, and tries again the thumbnails that failed. Quitting OpenReliant never waits for a download. The screen lists -at most 127 mods. A download can't be cancelled yet -([#1040](https://github.com/OpenReliant/openreliant/issues/1040)), and the mods screen doesn't say -yet which of its mods have an update +at most 127 mods. The mods screen doesn't say yet which of its mods have an update ([#1041](https://github.com/OpenReliant/openreliant/issues/1041)). OpenReliant records the mods GET MODS installs in `starlancer.ini`, in the section diff --git a/src/engine/game/bigfile/catalogue.zig b/src/engine/game/bigfile/catalogue.zig index bd4d8474..d35ac193 100644 --- a/src/engine/game/bigfile/catalogue.zig +++ b/src/engine/game/bigfile/catalogue.zig @@ -16,9 +16,11 @@ //! catalogue's digest, which it writes next to the archive as the archive's checksum file. The //! screen polls both once per frame, so the menus keep running during a download. An index or a //! thumbnail gives up after a minute, or 15 seconds without anything arriving, and an archive after -//! 30 seconds without anything arriving (`fetch_limits`, `install_limits`). A download the screen -//! lets go of, as OpenReliant quits, stops on its own thread; an archive's part file is deleted then, -//! unless OpenReliant has ended first, in which case the next install of the mod replaces it. +//! 30 seconds without anything arriving (`fetch_limits`, `install_limits`). An install the screen +//! cancels (`Install.cancel`) stops on its own thread and deletes its part file, and the screen +//! keeps polling it until it says so. A download the screen lets go of, as OpenReliant quits, stops +//! on its own thread too; an archive's part file is deleted then, unless OpenReliant has ended +//! first, in which case the next install of the mod replaces it. //! //! **Improvement:** the original has no mods, and never reads the web. @@ -381,6 +383,9 @@ pub const State = enum { done, /// The download failed; `failure` says why. failed, + /// The download was cancelled (`Install.cancel`) and its thread has stopped; `finish` forgets + /// it. + cancelled, }; /// Why a download failed. `words` says it as the screen shows it. @@ -614,6 +619,8 @@ pub const Install = struct { done: bool = false, /// Why the download failed, once it has. failure: ?Failure = null, + /// Whether the download was cancelled and its thread has stopped. + cancelled: bool = false, /// What the download's thread runs (`web.Download`). pub const Job = struct { @@ -686,7 +693,10 @@ pub const Install = struct { if (answer) |_| { log.info("installed the mod {s} in the mods folder", .{install.id.?}); install.done = true; - } else |err| { + } else |err| if (err == error.Canceled) { + log.info("the download of the mod {s} was cancelled", .{install.id.?}); + install.cancelled = true; + } else { log.warn("can't install the mod {s}: {s}", .{ install.id.?, @errorName(err) }); install.failure = .of(err); } @@ -694,6 +704,7 @@ pub const Install = struct { if (install.download.busy()) return .running; if (install.done) return .done; if (install.failure != null) return .failed; + if (install.cancelled) return .cancelled; return .idle; } @@ -703,12 +714,24 @@ pub const Install = struct { return progress.fraction(); } - /// Forgets the install once the download is done or has failed. Afterwards the install is idle - /// and can be started again. + /// Asks the download to stop (`web.Download.cancel`), while it runs. The install stays + /// `running` until its thread has stopped and deleted the part file, then is `cancelled`. An + /// archive that was complete just as it was cancelled is `done` instead. + pub fn cancel(install: *Install) void { + install.download.cancel(); + } + + /// Whether the download was cancelled and its thread hasn't stopped yet. + pub fn cancelling(install: Install) bool { + return install.download.cancelling(); + } + + /// Forgets the install once the download is done, has failed or was cancelled. Afterwards the + /// install is idle and can be started again. pub fn finish(install: *Install) void { switch (install.state()) { .idle, .running => return, - .done, .failed => install.release(), + .done, .failed, .cancelled => install.release(), } } @@ -1079,8 +1102,11 @@ pub const testing = struct { group: Io.Group = .init, /// A file the server serves at `path`: its body and status, for a redirect, where to, and - /// how long the server waits before it answers, as a server that has stalled does. - pub const File = struct { path: []const u8, body: []const u8, status: std.http.Status = .ok, location: ?[]const u8 = null, stall: ?Io.Duration = null }; + /// how long the server waits before it answers, as a server that has stalled does. A held + /// file is sent its head and one byte of its body, and nothing more until the client gives + /// up, as a transfer that has stalled midway does: the server serves nothing else until + /// then, so a test cancels or lets go of a held download before it asks for another file. + pub const File = struct { path: []const u8, body: []const u8, status: std.http.Status = .ok, location: ?[]const u8 = null, stall: ?Io.Duration = null, held: bool = false }; /// Starts serving `served` on a free port. pub fn start(server: *Server, io: Io, served: []const File) !void { @@ -1120,6 +1146,10 @@ pub const testing = struct { if (found) |file| { if (file.stall) |stall| io.sleep(stall, .awake) catch return; const headers: []const std.http.Header = if (file.location) |location| &.{.{ .name = "location", .value = location }} else &.{}; + if (file.held) { + hold(&request, &in.interface, file) catch {}; + continue; + } request.respond(file.body, .{ .status = file.status, .keep_alive = false, .extra_headers = headers }) catch {}; } else { request.respond("not here", .{ .status = .not_found, .keep_alive = false }) catch {}; @@ -1127,6 +1157,16 @@ pub const testing = struct { } } + /// Sends the head of a held `file` and the first byte of its body, then waits for the + /// client to close the connection, which `in` reads the end of, and sends nothing more. + fn hold(request: *std.http.Server.Request, in: *Io.Reader, file: File) !void { + var buffer: [web.chunk_size]u8 = undefined; + var body = try request.respondStreaming(&buffer, .{ .content_length = file.body.len, .respond_options = .{ .status = file.status, .keep_alive = false } }); + try body.writer.writeAll(file.body[0..@min(file.body.len, 1)]); + try body.flush(); + while (true) _ = try in.takeByte(); + } + /// Stops the server and waits for its thread: asks it for `/stop`, or closes its socket if /// that can't be asked, then closes the socket. pub fn stop(server: *Server, gpa: Allocator) void { @@ -1158,6 +1198,26 @@ pub const testing = struct { } return error.TestTimeout; } + + /// Polls until the file `path` exists in `dir`, as a download's part file does once the server + /// has answered. + pub fn waitForFile(dir: Io.Dir, path: []const u8) !void { + for (0..2000) |_| { + if (dir.access(std.testing.io, path, .{})) |_| return else |_| {} + try std.testing.io.sleep(.fromMilliseconds(5), .awake); + } + return error.TestTimeout; + } + + /// Polls until the file `path` is gone from `dir`, as a download's part file is once its thread + /// has stopped. + pub fn waitForNoFile(dir: Io.Dir, path: []const u8) !void { + for (0..2000) |_| { + if (dir.access(std.testing.io, path, .{})) |_| {} else |_| return; + try std.testing.io.sleep(.fromMilliseconds(5), .awake); + } + return error.TestTimeout; + } }; test "the catalogue and a mod download from the web, and the archive is checked on install" { @@ -1180,6 +1240,7 @@ test "the catalogue and a mod download from the web, and the archive is checked .{ .path = "/nowhere", .body = "", .status = .found }, .{ .path = "/elsewhere", .body = "", .status = .found, .location = "ws://127.0.0.1:1/alpha.hog" }, .{ .path = "/stalled.json", .body = index, .stall = .fromMilliseconds(400) }, + .{ .path = "/held.hog", .body = archive, .held = true }, }); defer server.stop(gpa); var buffer: [128]u8 = undefined; @@ -1300,4 +1361,33 @@ test "the catalogue and a mod download from the web, and the archive is checked try install.start(gpa, tmp.dir, remote); try std.testing.expectEqual(.failed, try testing.wait(&install)); try std.testing.expectEqual(.refused_address, install.failure.?); + install.finish(); + // A download cancelled midway stays running until its thread has stopped, then is cancelled: + // nothing is left in the mods folder, and the install can start again at once. + var held_url: [128]u8 = undefined; + var held = alpha; + held.id = "held"; + held.archive = server.url(&held_url, "/held.hog"); + try install.start(gpa, tmp.dir, held); + try testing.waitForFile(tmp.dir, "mods/held.hog.part"); + try std.testing.expect(!install.cancelling()); + install.cancel(); + try std.testing.expect(install.cancelling()); + try std.testing.expectEqual(.running, install.state()); + try std.testing.expectEqual(.cancelled, try testing.wait(&install)); + try std.testing.expect(!install.cancelling()); + try std.testing.expectEqual(null, install.failure); + try std.testing.expectError(error.FileNotFound, tmp.dir.access(io, "mods/held.hog.part", .{})); + try std.testing.expectError(error.FileNotFound, tmp.dir.access(io, "mods/held.hog", .{})); + install.finish(); + try std.testing.expectEqual(.idle, install.state()); + try install.start(gpa, tmp.dir, held); + try testing.waitForFile(tmp.dir, "mods/held.hog.part"); + // An install let go of deletes its part file on its own thread, and cancelling an idle + // install does nothing. + install.deinit(); + try testing.waitForNoFile(tmp.dir, "mods/held.hog.part"); + install.cancel(); + try std.testing.expect(!install.cancelling()); + try std.testing.expectEqual(.idle, install.state()); } diff --git a/src/engine/game/interface/mod_catalogue.zig b/src/engine/game/interface/mod_catalogue.zig index 36283091..cf436ac9 100644 --- a/src/engine/game/interface/mod_catalogue.zig +++ b/src/engine/game/interface/mod_catalogue.zig @@ -8,7 +8,9 @@ //! UPDATE does the same for a mod that is installed in an older version. RELOAD reads the //! repositories again. The indexes and the downloads run on other threads, so the screen keeps //! running while they come in. The repositories are read one after another, and one download -//! runs at a time: INSTALL and RELOAD wait for it. +//! runs at a time: INSTALL and RELOAD wait for it. While the chosen mod downloads, the INSTALL +//! button reads CANCEL and stops the download, which deletes its part file; the panel says +//! CANCELING until the download's thread has stopped. Leaving the screen lets a download go on. //! //! The screen uses the mods screen's layout and shapes: the framed list with its arrows, the //! panel, and the settings screen's buttons. OK goes back to the mods screen, which rereads the @@ -16,9 +18,8 @@ //! listed in red and can't be installed, and a folder mod of the same name, copied in by hand, is //! left as it is: the loader would load both it and the archive. //! -//! Not yet: cancelling a download that runs -//! ([#1040](https://github.com/OpenReliant/openreliant/issues/1040)), and updates shown on the -//! mods screen itself ([#1041](https://github.com/OpenReliant/openreliant/issues/1041)). +//! Not yet: updates shown on the mods screen itself +//! ([#1041](https://github.com/OpenReliant/openreliant/issues/1041)). //! //! **Improvement:** the original has no mods, and never reads the web. @@ -118,8 +119,8 @@ const bar_inside = 2; const bar_at: [2]i32 = .{ details_frame.at[0] + details_inside, details_frame.at[1] + frame_height - details_inside - bar_height }; const bar_width = details_lines.width; -/// The buttons, in the settings screen's places: INSTALL (or UPDATE) where RESET DEFAULTS is, and -/// RELOAD where CANCEL CHANGES is. +/// The buttons, in the settings screen's places: INSTALL (or UPDATE, or CANCEL while the chosen +/// mod downloads) where RESET DEFAULTS is, and RELOAD where CANCEL CHANGES is. pub const Button = enum { ok, leave, @@ -141,15 +142,20 @@ pub const Button = enum { fn shown(button: Button, action: Action) canvas_module.Button { return switch (button) { - .install => button.settingsButton().labelled(.{ .words = if (action == .update) "UPDATE" else "INSTALL" }), + .install => button.settingsButton().labelled(.{ .words = switch (action) { + .install, .none => "INSTALL", + .update => "UPDATE", + .cancel => "CANCEL", + } }), .reload => button.settingsButton().labelled(.{ .words = "RELOAD" }), .ok, .leave => button.settingsButton().shown(.game_options), }; } }; -/// What INSTALL does for the chosen mod: install it, update an older copy, or nothing. -const Action = enum { install, update, none }; +/// What the INSTALL button does for the chosen mod: install it, update an older copy, cancel its +/// download, or nothing. +const Action = enum { install, update, cancel, none }; /// Whether a mod of the catalogue is in the `mods` folder. const Status = union(enum) { @@ -475,11 +481,12 @@ pub const ModCatalogue = struct { return .absent; } - /// What INSTALL does for the mod at `index` in the catalogue. Nothing while a download runs, when - /// the mod needs a newer OpenReliant, when a folder mod of its name is installed, or when the - /// installed version is the catalogue's or newer. + /// What the INSTALL button does for the mod at `index` in the catalogue. While a download runs, + /// it cancels the download if it is this mod's and hasn't been cancelled yet, and nothing + /// otherwise. Nothing either when the mod needs a newer OpenReliant, when a folder mod of its + /// name is installed, or when the installed version is the catalogue's or newer. fn actionFor(screen: ModCatalogue, index: u8) Action { - if (screen.installing != null) return .none; + if (screen.installing) |downloading| return if (downloading == index and !screen.install.cancelling()) .cancel else .none; const entry = screen.entries()[index]; if (entry.needsLater(screen.running) != null) return .none; return switch (screen.statusOf(entry)) { @@ -527,7 +534,7 @@ pub const ModCatalogue = struct { .button => |button| switch (button) { .ok => return .{ .mods = .{ .installed = screen.installed_any } }, .leave => return .main_menu, - .install => screen.startInstall(context), + .install => screen.pressInstall(context), .reload => screen.reload(context), }, .scroll => |way| { @@ -546,7 +553,8 @@ pub const ModCatalogue = struct { const index = screen.reading orelse return; const gpa = context.source.gpa; switch (screen.fetch.state()) { - .idle, .running => return, + // Only an install is ever cancelled. + .idle, .running, .cancelled => return, .failed => { const name = if (Repositories.of(context.settings_file.profile).at(index)) |repository| repository.name else ""; screen.recordFailure(gpa, name, screen.fetch.failure.?); @@ -584,10 +592,19 @@ pub const ModCatalogue = struct { screen.startReading(context); } - /// INSTALL or UPDATE: starts downloading the selected mod, if there is something to do. - fn startInstall(screen: *ModCatalogue, context: Context) void { + /// The INSTALL button: starts downloading the selected mod (INSTALL, UPDATE), or stops its + /// download (CANCEL), if there is something to do. + fn pressInstall(screen: *ModCatalogue, context: Context) void { const index = screen.chosen orelse return; - if (screen.actionFor(index) == .none) return; + switch (screen.actionFor(index)) { + .none => {}, + .cancel => screen.install.cancel(), + .install, .update => screen.startInstall(context, index), + } + } + + /// Starts downloading the mod at `index` in the catalogue. + fn startInstall(screen: *ModCatalogue, context: Context, index: u8) void { const source = context.source; const entry = screen.entries()[index]; screen.forgetOutcome(); @@ -600,13 +617,18 @@ pub const ModCatalogue = struct { } /// Once the install is done or has failed, rereads the `mods` folder and keeps the result for the - /// panel. + /// panel. Once a cancelled install has stopped, forgets it: the panel shows the mod as before. fn takeInstall(screen: *ModCatalogue, context: Context) void { const index = screen.installing orelse return; const failure: ?catalogue.Failure = switch (screen.install.state()) { .idle, .running => return, .done => null, .failed => screen.install.failure, + .cancelled => { + screen.install.finish(); + screen.installing = null; + return; + }, }; screen.install.finish(); screen.installing = null; @@ -651,7 +673,8 @@ pub const ModCatalogue = struct { fn takeThumbnail(screen: *ModCatalogue, source: mod_manager.Source) void { const gpa = source.gpa; switch (screen.thumbnail_fetch.state()) { - .running => return, + // Only an install is ever cancelled. + .running, .cancelled => return, .idle => {}, .done, .failed => screen.keepThumbnail(gpa), } @@ -836,13 +859,15 @@ pub const ModCatalogue = struct { } /// The status line of the mod at `index` in the catalogue, written into `buffer`: the download's - /// progress, the result of its install, that it needs a newer OpenReliant, that a folder mod of - /// its name was installed by hand, or the installed version, BY HAND where the settings file - /// doesn't record the install, and, if the catalogue's is newer, the update. Null when there is nothing to say. The versions are shown as the manifest and - /// the catalogue write them. + /// progress, or CANCELING once it is cancelled, the result of its install, that it needs a newer + /// OpenReliant, that a folder mod of its name was installed by hand, or the installed version, + /// BY HAND where the settings file doesn't record the install, and, if the catalogue's is + /// newer, the update. Null when there is nothing to say. The versions are shown as the manifest + /// and the catalogue write them. fn statusLine(screen: ModCatalogue, buffer: *[name_buffer]u8, index: u8) ?Note { const entry = screen.entries()[index]; if (screen.installing == index) { + if (screen.install.cancelling()) return .{ .text = "CANCELING", .colour = canvas_module.gold }; const text = if (screen.install.fraction()) |fraction| std.mem.print(buffer, "DOWNLOADING {d}%", .{@as(u32, @intFromFloat(fraction * 100))}) catch "DOWNLOADING" else @@ -1224,6 +1249,57 @@ test "UPDATE downloads the mod into the mods folder, and the mods screen is told try std.testing.expectEqual(Leave{ .mods = .{ .installed = true } }, fixture.click(Button.ok.rect().centre()).?); } +test "CANCEL stops the download of the chosen mod, and nothing is left of it" { + const gpa = std.testing.allocator; + const io = std.testing.io; + const archive = try hog.build(gpa, &.{.{ .name = "mod.ini", .data = "[Mod]\nName=Alpha mod\nVersion=1.3\n" }}); + defer gpa.free(archive); + const digest = std.fmt.bytesToHex(checksums.digest(archive), .lower); + var fixture: Fixture = undefined; + var server: catalogue.testing.Server = undefined; + // The server sends one byte of the archive, then holds the rest. + try server.start(io, &.{.{ .path = "/alpha.hog", .body = archive, .held = true }}); + var archive_url: [128]u8 = undefined; + var text: [1024]u8 = undefined; + try fixture.init(Fixture.index(&text, server.url(&archive_url, "/alpha.hog"), archive.len, &digest)); + defer fixture.deinit(); + defer server.stop(gpa); + const screen = &fixture.screen; + // UPDATE starts the download. The button then reads CANCEL while Alpha is chosen, and does + // nothing while another mod is. + try std.testing.expectEqual(null, fixture.click(Button.install.rect().centre())); + try std.testing.expectEqual(0, screen.installing.?); + try std.testing.expectEqual(.cancel, screen.action()); + try std.testing.expect(screen.usable(.install)); + try std.testing.expectEqual(null, fixture.click(nameCentre(2))); + try std.testing.expectEqual(1, screen.chosen.?); + try std.testing.expectEqual(.none, screen.action()); + try std.testing.expect(!screen.usable(.install)); + try std.testing.expectEqual(null, fixture.click(nameCentre(1))); + try std.testing.expectEqual(0, screen.chosen.?); + try catalogue.testing.waitForFile(fixture.tmp.dir, "mods/alpha.hog.part"); + // CANCEL asks the download to stop. Until its thread has stopped, the panel says CANCELING and + // the button does nothing. + try std.testing.expectEqual(null, fixture.click(Button.install.rect().centre())); + try std.testing.expect(screen.install.cancelling()); + try std.testing.expectEqual(0, screen.installing.?); + try std.testing.expectEqual(.none, screen.action()); + var buffer: [name_buffer]u8 = undefined; + try std.testing.expectEqualStrings("CANCELING", screen.statusLine(&buffer, 0).?.text); + try std.testing.expectEqual(.cancelled, try catalogue.testing.wait(&screen.install)); + // The next pass forgets the download: nothing was installed, the part file is gone, and the + // panel shows Alpha as before, with its update to get. + try std.testing.expectEqual(null, screen.frame(fixture.context(.{}))); + try std.testing.expectEqual(null, screen.installing); + try std.testing.expect(!screen.installed_any); + try std.testing.expect(screen.outcome == null); + try std.testing.expect(screen.canReload()); + try std.testing.expectEqual(.update, screen.action()); + try std.testing.expectEqualStrings("INSTALLED 1.2 BY HAND, UPDATE TO 1.3", screen.statusLine(&buffer, 0).?.text); + try std.testing.expectError(error.FileNotFound, fixture.tmp.dir.access(io, "mods/alpha.hog.part", .{})); + try std.testing.expectEqual(Leave{ .mods = .{ .installed = false } }, fixture.click(Button.ok.rect().centre()).?); +} + test sizeText { var buffer: [32]u8 = undefined; try std.testing.expectEqualStrings("30.8 MB", sizeText(&buffer, 32297124)); diff --git a/src/engine/web.zig b/src/engine/web.zig index ae2f7c51..fa260ed6 100644 --- a/src/engine/web.zig +++ b/src/engine/web.zig @@ -14,9 +14,10 @@ //! there gets the start of a secure connection that it can't complete, never a request. //! - Each download runs on a thread of its own, with an `Io` and an HTTP client of its own //! (`Download`). The game never waits for it: each frame it looks at whether the download has -//! ended, and as it quits it leaves the thread behind. A slow or missing connection never holds -//! the game up, not even a lookup of a host's address, which the system's resolver does in a call -//! nothing can interrupt. +//! ended, and as it quits it leaves the thread behind. It can ask a download to stop and keep +//! polling until the thread says it has (`Download.cancel`), as CANCEL on GET MODS does. A slow +//! or missing connection never holds the game up, not even a lookup of a host's address, which +//! the system's resolver does in a call nothing can interrupt. //! - A download gives up past its limits (`Limits`): a time for the whole download, and a time //! without anything arriving, so that a server that stops answering doesn't leave it waiting for //! good. @@ -317,7 +318,8 @@ pub const FetchError = @typeInfo(@typeInfo(@TypeOf(fetch)).@"fn".return_type.?). /// `Job` has `fn run(job: *Job, io: Io, progress: *Progress) !Output`, called on the /// thread with an `Io` of its own, and may have `fn deinit(job: *Job) void`, which frees what the /// job holds, and `fn discard(output: Output) void`, which frees an answer nobody took. The game -/// polls it each frame (`take`), and lets go of it without waiting (`abandon`). +/// polls it each frame (`take`). It can ask the download to stop and wait for it to say so +/// (`cancel`), or let go of it without waiting (`abandon`). pub fn Download(comptime Job: type) type { return struct { /// What it shares with its thread, from `start` until it takes the answer or lets go. @@ -328,7 +330,7 @@ pub fn Download(comptime Job: type) type { /// What `Job.run` gives when it succeeds. pub const Output = Returned.payload; /// Why a download fails: `Job.run`'s errors, no thread to run it on, its limits - /// (`error.Timeout`), or being let go of (`error.Canceled`). + /// (`error.Timeout`), or being stopped (`error.Canceled`), by `cancel` or `abandon`. pub const Error = Returned.error_set || Io.ConcurrentError || error{ Timeout, Canceled }; /// What the download and its thread share. It is on the heap, so that the thread can @@ -341,8 +343,9 @@ pub fn Download(comptime Job: type) type { answered: std.atomic.Value(bool) = .init(false), answer: Error!Output = undefined, taken: bool = false, - /// Whether the download has let go, so that the thread stops at its next step. - abandoned: std.atomic.Value(bool) = .init(false), + /// Whether the download asked the thread to stop (`cancel`, `abandon`), which it does + /// at its next step. + stop: std.atomic.Value(bool) = .init(false), progress: Progress = .{}, job: Job, limits: Limits, @@ -410,18 +413,34 @@ pub fn Download(comptime Job: type) type { return answer; } + /// Asks the download to stop, and keeps it until it has. Its thread stops at its next step, + /// or once a lookup of a host's address ends, then answers `error.Canceled`, which `take` + /// returns as usual. The game keeps polling until then, so it knows when the thread is done + /// with what it holds, such as an archive's part file. A job that returns just as it is + /// asked to stop answers with its own result instead. Does nothing when nothing runs. + pub fn cancel(download: *Self) void { + const shared = download.shared orelse return; + shared.stop.store(true, .release); + } + + /// Whether `cancel` asked the download to stop and its answer hasn't been taken yet. + pub fn cancelling(download: Self) bool { + const shared = download.shared orelse return false; + return shared.stop.load(.acquire); + } + /// Lets go of the download without waiting for it, as the game quits or forgets it. Its /// thread stops at its next step, or once a lookup of a host's address ends, and frees what /// it holds. Afterwards the download is idle. pub fn abandon(download: *Self) void { const shared = download.shared orelse return; - shared.abandoned.store(true, .release); + shared.stop.store(true, .release); shared.letGo(); download.shared = null; } /// The thread, with an `Io` of its own, which `main`'s doesn't wait for as OpenReliant quits: - /// runs the job as a task it cancels once the job is past its limits or abandoned, and + /// runs the job as a task it cancels once the job is past its limits or asked to stop, and /// leaves the answer in `shared`. fn work(shared: *Shared) void { defer shared.letGo(); @@ -430,7 +449,7 @@ pub fn Download(comptime Job: type) type { const io = threaded.io(); var returned: Io.Event = .unset; var task = io.concurrent(run, .{ shared, io, &returned }) catch |err| return shared.give(err); - const ended = watch(io, &returned, &shared.progress, &shared.abandoned, shared.limits); + const ended = watch(io, &returned, &shared.progress, &shared.stop, shared.limits); // Cancelling can't interrupt a lookup of a host's address, so it waits for the lookup to // end; the game doesn't wait for this thread. const answer = task.cancel(io); @@ -448,17 +467,18 @@ pub fn Download(comptime Job: type) type { } /// How a download's job ended (`watch`). -const Ended = enum { returned, timed_out, abandoned }; +const Ended = enum { returned, timed_out, stopped }; /// Waits for a job to return, looking every `watch_step` at whether it is past `limits`, by the -/// time since it started and since something last arrived (`Progress.arrivals`), or `abandoned`. -fn watch(io: Io, returned: *Io.Event, progress: *const Progress, abandoned: *const std.atomic.Value(bool), limits: Limits) Ended { +/// time since it started and since something last arrived (`Progress.arrivals`), or asked to +/// `stop`. +fn watch(io: Io, returned: *Io.Event, progress: *const Progress, stop: *const std.atomic.Value(bool), limits: Limits) Ended { const started = Io.Clock.awake.now(io); var arrivals = progress.arrivals.load(.monotonic); var heard_at = started; while (true) { if (returned.waitTimeout(io, .{ .duration = .{ .raw = watch_step, .clock = .awake } })) |_| return .returned else |_| {} - if (abandoned.load(.acquire)) return .abandoned; + if (stop.load(.acquire)) return .stopped; const now = Io.Clock.awake.now(io); if (limits.whole) |whole| if (started.durationTo(now).nanoseconds >= whole.nanoseconds) return .timed_out; const counted = progress.arrivals.load(.monotonic); @@ -586,6 +606,24 @@ test Download { try std.testing.expectEqual(8, try (try testing.answer(&trickles))); } +test "a cancelled download stays busy until its thread has stopped, then answers so" { + var slow: Download(testing.Slow) = .{}; + // Cancelling nothing does nothing. + slow.cancel(); + try std.testing.expect(!slow.cancelling()); + try slow.start(.{}, .{}); + try std.testing.expect(!slow.cancelling()); + slow.cancel(); + try std.testing.expect(slow.busy()); + try std.testing.expect(slow.cancelling()); + try std.testing.expectError(error.Canceled, try testing.answer(&slow)); + try std.testing.expect(!slow.busy()); + try std.testing.expect(!slow.cancelling()); + // It can start again at once. + try slow.start(.{}, .{ .whole = .fromMilliseconds(30) }); + try std.testing.expectError(error.Timeout, try testing.answer(&slow)); +} + test "the game never waits for a download's thread" { // A thread that can't be stopped for half a second: polling it and letting go take no time. const io = std.testing.io;