From e6986a019b7814345de2e22a47373f6fa3689d4b Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Tue, 4 Aug 2026 09:52:41 +0000 Subject: [PATCH 1/2] fix(launcher): never replace a profile state file we could not read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A profile that had worked for months came up unusable: a blank side panel, a New Tab that never finished loading. A fresh profile on the same machine, same build, same engine, with the same extension loaded, was fine — so the profile was the fault, not the code it ran. Three blocks here edit Chromium's JSON state. Every one of them did: try: d = json.load(open(p)) if os.path.exists(p) else {} except Exception: d = {} ... json.dump(d, open(p, "w")) Both halves are wrong, and they feed each other. The write is in-place and truncating, so an interrupted launch leaves a half-written file. The read then treats that file as absent and writes a stub holding only the key that block cared about. Default/Preferences IS the profile — search engine, startup, every extension's state — so the second launch after an interrupted one silently factory-resets it, and nothing says so. So: a state file that exists and does not parse is now left exactly as found, with a line on stderr naming it. Refusing to write is not refusing to start — the browser still launches, just without that block's setting applied. And all three writes now land as a rename (fsync, then os.replace) instead of in place, so there is no longer a truncated file for the next launch to misread. #75 fixed the write half for the search block alone and left its read, and left the other two blocks untouched; this finishes the job. Tests: 5 cases — a corrupt Preferences and a corrupt Local State survive a launch byte-for-byte, the refusal is announced, the browser still starts, and setting restore_on_startup keeps every unrelated key. Verified 3 of the 5 fail against the pre-fix launcher. Co-Authored-By: Claude Opus 5 (1M context) --- apps/desktop/launcher/tronbrowser | 89 ++++++++++++++++++++++++------ apps/desktop/node_modules | 1 + apps/desktop/test/launcher.test.ts | 65 ++++++++++++++++++++++ 3 files changed, 138 insertions(+), 17 deletions(-) create mode 120000 apps/desktop/node_modules diff --git a/apps/desktop/launcher/tronbrowser b/apps/desktop/launcher/tronbrowser index 5245259..58f248b 100755 --- a/apps/desktop/launcher/tronbrowser +++ b/apps/desktop/launcher/tronbrowser @@ -170,19 +170,42 @@ fi # "Always prompt for install". Pre-seed it in the profile's Local State so the # bundled chromium-web-store can install extensions. Best-effort (needs python3). if command -v python3 >/dev/null 2>&1; then - TB_LOCAL_STATE="$DATA/Local State" python3 - <<'PY' 2>/dev/null || true -import json, os + TB_LOCAL_STATE="$DATA/Local State" python3 - <<'PY' || true +import json, os, sys, tempfile p = os.environ["TB_LOCAL_STATE"] os.makedirs(os.path.dirname(p), exist_ok=True) -try: - d = json.load(open(p)) if os.path.exists(p) else {} -except Exception: +# An existing file that does not parse is LEFT ALONE. Replacing it with a fresh +# dict is how a profile gets wiped: this file holds every setting in the profile, +# so a stub is indistinguishable from a factory reset — and the usual reason it +# does not parse is a truncating write like the one this block used to do. +if os.path.exists(p): + try: + with open(p) as f: + d = json.load(f) + except Exception: + sys.stderr.write("TronBrowser: %s did not parse — leaving it untouched.\n" % p) + raise SystemExit(0) +else: d = {} exp = d.setdefault("browser", {}).setdefault("enabled_labs_experiments", []) flag = "extension-mime-request-handling@2" if flag not in exp: exp.append(flag) - json.dump(d, open(p, "w")) + # Land it as a rename. This file is the profile; a truncated in-place write + # loses every setting in it, and the next launch then reads it as unparseable. + fd, tmp = tempfile.mkstemp(dir=os.path.dirname(p)) + try: + with os.fdopen(fd, "w") as f: + json.dump(d, f) + f.flush() + os.fsync(f.fileno()) + os.replace(tmp, p) + except Exception: + try: + os.unlink(tmp) + except OSError: + pass + raise PY fi @@ -240,16 +263,25 @@ if command -v python3 >/dev/null 2>&1 && [ "$SEARCH_PREV" != "$SEARCH_WANT" ]; t TB_ENGINE="$(search_engine_spec "$SEARCH_WANT")" \ TB_PREV_URL="$SEARCH_PREV_URL" \ TB_FORCE="$SEARCH_EXPLICIT" \ - python3 - <<'PY' 2>/dev/null || true -import json, os, tempfile + python3 - <<'PY' || true +import json, os, sys, tempfile p = os.environ["TB_PREFS"] name, keyword, url = os.environ["TB_ENGINE"].split("|") prev_url = os.environ.get("TB_PREV_URL", "") force = os.environ.get("TB_FORCE") == "1" os.makedirs(os.path.dirname(p), exist_ok=True) -try: - d = json.load(open(p)) if os.path.exists(p) else {} -except Exception: +# An existing file that does not parse is LEFT ALONE. Replacing it with a fresh +# dict is how a profile gets wiped: this file holds every setting in the profile, +# so a stub is indistinguishable from a factory reset — and the usual reason it +# does not parse is a truncating write like the one this block used to do. +if os.path.exists(p): + try: + with open(p) as f: + d = json.load(f) + except Exception: + sys.stderr.write("TronBrowser: %s did not parse — leaving it untouched.\n" % p) + raise SystemExit(0) +else: d = {} # Only ever overwrite an engine that is absent or that we put there ourselves. @@ -284,16 +316,39 @@ fi # goes through the new-tab path, which honors the chrome_url_overrides feed. Once # per profile, then respect the user's chrome://settings/onStartup choice. if command -v python3 >/dev/null 2>&1 && [ ! -f "$DATA/.tron-startup-ntp" ]; then - TB_PREFS="$DATA/Default/Preferences" python3 - <<'PY' 2>/dev/null || true -import json, os + TB_PREFS="$DATA/Default/Preferences" python3 - <<'PY' || true +import json, os, sys, tempfile p = os.environ["TB_PREFS"] os.makedirs(os.path.dirname(p), exist_ok=True) -try: - d = json.load(open(p)) if os.path.exists(p) else {} -except Exception: +# An existing file that does not parse is LEFT ALONE. Replacing it with a fresh +# dict is how a profile gets wiped: this file holds every setting in the profile, +# so a stub is indistinguishable from a factory reset — and the usual reason it +# does not parse is a truncating write like the one this block used to do. +if os.path.exists(p): + try: + with open(p) as f: + d = json.load(f) + except Exception: + sys.stderr.write("TronBrowser: %s did not parse — leaving it untouched.\n" % p) + raise SystemExit(0) +else: d = {} d.setdefault("session", {})["restore_on_startup"] = 5 # 5 = open the New Tab page -json.dump(d, open(p, "w")) +# Land it as a rename. This file is the profile; a truncated in-place write +# loses every setting in it, and the next launch then reads it as unparseable. +fd, tmp = tempfile.mkstemp(dir=os.path.dirname(p)) +try: + with os.fdopen(fd, "w") as f: + json.dump(d, f) + f.flush() + os.fsync(f.fileno()) + os.replace(tmp, p) +except Exception: + try: + os.unlink(tmp) + except OSError: + pass + raise PY mkdir -p "$DATA"; : > "$DATA/.tron-startup-ntp" fi diff --git a/apps/desktop/node_modules b/apps/desktop/node_modules new file mode 120000 index 0000000..b7667f2 --- /dev/null +++ b/apps/desktop/node_modules @@ -0,0 +1 @@ +/home/anthony/src/profullstack/tronbrowser.dev/apps/desktop/node_modules \ No newline at end of file diff --git a/apps/desktop/test/launcher.test.ts b/apps/desktop/test/launcher.test.ts index 9d01547..d4a3946 100644 --- a/apps/desktop/test/launcher.test.ts +++ b/apps/desktop/test/launcher.test.ts @@ -226,6 +226,71 @@ describe('omnibox search engine', () => { }); }); +// --- Damaged profile --------------------------------------------------------- +// Three blocks in the launcher edit Chromium's JSON state files. Every one of +// them used to do `except Exception: d = {}` and then write the result — so a +// file that failed to parse was REPLACED by a stub holding only the key that +// block cared about. Preferences is the whole profile, which made that a silent +// factory reset, and the usual reason it failed to parse was the non-atomic +// write the same block did on the previous launch. These tests pin the rule: +// a state file we cannot read is a file we do not touch. + +const localStatePath = (home: string) => join(home, 'profile', 'Local State'); + +/** A profile whose JSON state files are present but corrupt. */ +function seedCorrupt(which: 'prefs' | 'localstate'): { home: string; path: string } { + const home = mkdtempSync(join(tmpdir(), 'tron-launcher-')); + homes.push(home); + const path = which === 'prefs' ? prefsPath(home) : localStatePath(home); + mkdirSync(dirname(path), { recursive: true }); + // What a truncated write leaves behind: valid JSON's opening, then nothing. + writeFileSync(path, '{"default_search_provider_data": {"template_ur'); + return { home, path }; +} + +describe('damaged profile', () => { + it('leaves an unparseable Preferences exactly as it found it', () => { + const { home, path } = seedCorrupt('prefs'); + const before = readFileSync(path, 'utf8'); + run([], { home }); + expect(readFileSync(path, 'utf8')).toBe(before); + }); + + it('leaves an unparseable Local State exactly as it found it', () => { + const { home, path } = seedCorrupt('localstate'); + const before = readFileSync(path, 'utf8'); + run([], { home }); + expect(readFileSync(path, 'utf8')).toBe(before); + }); + + it('says which file it refused to touch, rather than failing silently', () => { + const { home } = seedCorrupt('prefs'); + const { stderr } = run([], { home }); + expect(stderr).toContain('did not parse'); + }); + + it('still launches the browser when a state file is unreadable', () => { + // Refusing to write must not become refusing to start. + const { home } = seedCorrupt('prefs'); + const { argv } = run([], { home }); + expect(valueOf(argv, '--user-data-dir')).toEqual([join(home, 'profile')]); + }); + + it('keeps unrelated settings when it sets the startup page', () => { + // The restore_on_startup block rewrites the whole file to change one key. + const home = mkdtempSync(join(tmpdir(), 'tron-launcher-')); + homes.push(home); + const path = prefsPath(home); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, JSON.stringify({ bookmark_bar: { show_on_all_tabs: true }, extensions: { settings: { abc: 1 } } })); + run([], { home }); + const json = JSON.parse(readFileSync(path, 'utf8')); + expect(json.session?.restore_on_startup).toBe(5); + expect(json.bookmark_bar?.show_on_all_tabs).toBe(true); + expect(json.extensions?.settings?.abc).toBe(1); + }); +}); + describe('engine reporting', () => { it('names the engine it is about to run', () => { const { stderr } = run([], { version: 'Chromium 141.0.0.0' }); From c0eab4306859d4d18b229f603d084d4acb7091ee Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Tue, 4 Aug 2026 10:03:06 +0000 Subject: [PATCH 2/2] fix(ci): drop a committed node_modules symlink, and ignore it properly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `pnpm install --frozen-lockfile` failed on this branch with ENOENT trying to mkdir apps/desktop/node_modules — because the path was checked in as a symlink pointing at an absolute path that exists on no runner. It got committed because .gitignore said `node_modules/`, and a trailing slash matches directories only. A symlink named node_modules — which is what running the suite against a hoisted store leaves behind — is not a directory, so it was never ignored. Dropping the slash covers both. Co-Authored-By: Claude Opus 5 (1M context) --- .gitignore | 2 +- apps/desktop/node_modules | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) delete mode 120000 apps/desktop/node_modules diff --git a/.gitignore b/.gitignore index 130cdc4..a44a176 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ -node_modules/ +node_modules dist/ build/ out/ diff --git a/apps/desktop/node_modules b/apps/desktop/node_modules deleted file mode 120000 index b7667f2..0000000 --- a/apps/desktop/node_modules +++ /dev/null @@ -1 +0,0 @@ -/home/anthony/src/profullstack/tronbrowser.dev/apps/desktop/node_modules \ No newline at end of file