Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion guake/guake_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -1439,8 +1439,22 @@ def save_tabs(self, filename="session.json"):
if not self.get_xdg_config_directory().exists():
self.get_xdg_config_directory().mkdir(parents=True)
session_file = self.get_xdg_config_directory() / filename
with session_file.open("w", encoding="utf-8") as f:
# Write atomically so an unclean shutdown (crash, power loss) can never
# leave a half-written session file: dump to a sibling temp file, fsync
# it, then os.replace() onto the real path. os.replace is atomic, so a
# restore always sees either the previous complete file or the new one.
# Finally fsync the directory so the rename itself survives power loss.
tmp_file = session_file.with_name(f"{filename}.tmp")
with tmp_file.open("w", encoding="utf-8") as f:
json.dump(config, f, ensure_ascii=False, indent=4)
f.flush()
os.fsync(f.fileno())
os.replace(tmp_file, session_file)
dir_fd = os.open(session_file.parent, os.O_RDONLY)
try:
os.fsync(dir_fd)
finally:
os.close(dir_fd)
log.info("Guake tabs saved to %s", session_file)

def restore_tabs(self, filename="session.json", suppress_notify=False):
Expand Down
18 changes: 18 additions & 0 deletions guake/tests/test_guake.py
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,24 @@ def test_guake_save_tabs_and_restore(mocker, g, fs):
assert nb.get_tab_text_index(2) == "python"


def test_guake_save_tabs_is_atomic(mocker, g, fs):
# save_tabs must write via a temp file + os.replace so an unclean shutdown
# can never leave a half-written session.json. After a successful save the
# real file is valid JSON and no leftover temp file remains. Both the temp
# file and its directory are fsynced, so the rename survives power loss.
mocker.patch.object(g.settings.general, "get_boolean", return_value=False)
fsync = mocker.spy(os, "fsync")

g.save_tabs()

assert fsync.call_count == 2
assert os.path.exists("/foobar/session.json")
assert not os.path.exists("/foobar/session.json.tmp")
with open("/foobar/session.json", encoding="utf-8") as f:
config = json.load(f)
assert "schema_version" in config
Comment on lines +195 to +200


def test_guake_hide_tab_bar_if_one_tab(mocker, g, fs):
# Set hide-tabs-if-one-tab to True
mocker.patch.object(g.settings.general, "get_boolean", return_value=True)
Expand Down
8 changes: 8 additions & 0 deletions releasenotes/notes/crash-safe-save-tabs-adf00755bead6ea5.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
fixes:
- |
Saving the tabs session is now atomic: Guake writes to a temporary file,
flushes it to disk, replaces ``session.json`` in a single step and syncs
the directory so the rename itself is durable. An unclean shutdown (crash
or power loss) during a save can no longer leave a truncated
``session.json`` that would be discarded on the next restore, so restored
tabs survive abnormal shutdowns.
Loading