From f859d23cd69625222a7c69d8c094e84e63fa1464 Mon Sep 17 00:00:00 2001 From: dsent <8774536+dsent@users.noreply.github.com> Date: Tue, 21 Jul 2026 08:47:46 +0000 Subject: [PATCH] =?UTF-8?q?feat(fs):=20durability=20API=20=E2=80=94=20FS.f?= =?UTF-8?q?sync(path)=20and=20FS.sync()?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Platform-level half of the durable-writes work, split out of the editor series at the maintainer level: this is shared FS API, not editor code. - util/filesystem.lua: FS.fsync(path) and FS.sync() over a pcall-guarded LuaJIT FFI cdef (open/fsync/close/ sync), degrading to a no-op where the syscalls are unavailable. FS.write stays async by contract — bulk deploy/clone and the user-facing writefile must not stall on the card — with durability as an explicit opt-in for callers that promise it. Vendored nativefs is untouched. - controller/controller.lua: whole-filesystem sync() on love.quit, plus focus(false)/visible(false) handlers, previously skipped, that sync when the app goes to the background. Split from c5866b2 (the editor's per-accept fsync and save-failure refusal follow in the editor series, which consumes this API). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_011WEJighCCuSFparAVqjsWH --- src/controller/controller.lua | 36 ++++++++++++++++++++-- src/util/filesystem.lua | 58 +++++++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+), 3 deletions(-) diff --git a/src/controller/controller.lua b/src/controller/controller.lua index d577a472..d9ed1213 100644 --- a/src/controller/controller.lua +++ b/src/controller/controller.lua @@ -4,6 +4,7 @@ require("view.view") require("util.string.string") require("util.key") local LANG = require("util.eval") +local FS = require("util.filesystem") local messages = { user_break = "BREAK into program", @@ -451,6 +452,10 @@ Controller = { local cfg = CC.cfg local function quit() + --- flush pending writes before the process can exit + --- (spec 2.6): a graceful quit loses nothing. One + --- syscall; force-stop is covered by per-accept fsync + FS.sync() if love.state.app_state == 'shutdown' then return false end @@ -471,6 +476,30 @@ Controller = { love.quit = quit end, + --- Background durability net (spec 2.6): a child + --- leaving the app flushes pending writes. One syscall + --- on focus loss; does not cover a force-stop mid-edit + --- (per-accept fsync does). + --- @private + --- @param CC ConsoleController + set_love_focus = function(CC) + local function focus(f) + if not f then FS.sync() end + end + love.focus = focus + end, + + --- Companion to focus: Android reports a backgrounded + --- window as not visible; flush there too. + --- @private + --- @param CC ConsoleController + set_love_visible = function(CC) + local function visible(v) + if not v then FS.sync() end + end + love.visible = visible + end, + ---------------- --- public --- ---------------- @@ -493,10 +522,11 @@ Controller = { --- SKIPPED joystick and gamepad support - --- intented to run as kiosk app - --- SKIPPED focus + --- intented to run as kiosk app; focus/visible are + --- wired only to flush pending writes on background + Controller.set_love_focus(CC) + Controller.set_love_visible(CC) --- SKIPPED mousefocus - --- SKIPPED visible --- SKIPPED resize --- SKIPPED filedropped --- SKIPPED directorydropped diff --git a/src/util/filesystem.lua b/src/util/filesystem.lua index 70f48b46..1e7e3746 100644 --- a/src/util/filesystem.lua +++ b/src/util/filesystem.lua @@ -202,6 +202,64 @@ if love and not TESTING then return FS.read(path, true) or FS.read(path) end + --- Durability helpers (bionic/glibc via LuaJIT FFI). + --- FS.write is async (see its contract below); the + --- editor accept path opts into durability with + --- FS.fsync, and lifecycle handlers use FS.sync as a + --- cheap whole-filesystem net. + local _durable = (function() + local ffi_ok, ffi = pcall(require, 'ffi') + if not ffi_ok then return nil end + pcall(ffi.cdef, [[ + int open(const char* path, int flags); + int close(int fd); + int fsync(int fd); + void sync(void); + ]]) + local O_RDONLY = 0 + return { + file = function(path) + local fd = ffi.C.open(path, O_RDONLY) + if fd < 0 then return false end + local r = ffi.C.fsync(fd) + ffi.C.close(fd) + return r == 0 + end, + all = function() ffi.C.sync() end, + } + end)() + + --- Flush one file's data through to stable storage. + --- The editor accept path calls this after a save + --- (spec 2.6 "written immediately"). Do NOT add it to + --- FS.write: bulk deploy/clone and the user-facing + --- writefile must stay async. Best-effort — returns + --- false when the platform lacks the syscall or the + --- path cannot be opened. + --- @param path string + --- @return boolean durable + function FS.fsync(path) + if not _durable then return false end + local ok, res = pcall(_durable.file, path) + return (ok and res) or false + end + + --- Flush all pending writes filesystem-wide in one + --- syscall. Cheap broad net for background/quit; does + --- not cover a force-stop mid-edit (that is FS.fsync). + --- @return boolean ran + function FS.sync() + if not _durable then return false end + return pcall(_durable.all) + end + + --- Write data to path, overwriting. Async by default: + --- the bytes reach the OS but are NOT flushed to stable + --- storage, so a power-cut or SIGKILL can lose them + --- while an (exfat dirsync) directory entry persists. + --- Callers needing durability opt in via FS.fsync(path) + --- after a successful write — the editor accept path + --- does; bulk deploy/clone and writefile do not. --- @param path string --- @param data string --- @return boolean success