Write config files atomically, and start with defaults when one is corrupt - #1115
Draft
notluquis wants to merge 36 commits into
Draft
Write config files atomically, and start with defaults when one is corrupt#1115notluquis wants to merge 36 commits into
notluquis wants to merge 36 commits into
Conversation
…rrupt jupyterlab#824 has two halves and this is the first: the app was producing the corrupt files it then could not start from. All three config writes went straight at the destination with fs.writeFileSync, which truncates first and writes second. One of the callers is the will-quit handler, which is the moment the OS is most likely to kill the process, and a truncated settings.json is exactly what the issue reports. Writes now go through one helper following the five steps in https://lwn.net/Articles/457667/: a temporary in the same directory, fsync it, rename it over the target, fsync the directory so the new name survives too. That last step is the one every JavaScript implementation I looked at skips, including VS Code, write-file-atomic and atomically. Borrowed rather than invented: - the path is resolved with realpath first, so a config symlinked into a dotfiles repo keeps both its link and its atomicity. write-file-atomic and atomically resolve the same way; VS Code declines the atomic path for symlinks and writes in place instead. - the mode is carried across by opening the temporary at it, so the file is never briefly wider than the one it replaces, and the umask is undone with fchmod afterwards. Ownership is carried when the process is root, so a single sudo jlab does not leave the file owned by root and the user locked out of their own settings. - failure is reported rather than thrown. will-quit calls this between preventDefault and quit with no try, so an exception there leaves the app unable to close. Reading no longer throws and no longer moves anything. UserSettings.read and ApplicationData.read parsed with no guard from constructors that run while their modules are being imported, so a truncated file threw before app.whenReady and the only thing the user saw was Electron's default error dialog. A file that cannot be read is left exactly where it is and marked, and writes to it are refused for the rest of the run. That is VS Code's rule, which has an error code for it, JSONEditingErrorCode.ERROR_INVALID_FILE. electron-store clears instead, and its readme names the criterion that separates the two: whether the file is one people edit by hand. troubleshoot.md tells readers to add logLevel to settings.json themselves, so ours is. Two shapes that are not corruption: a UTF-8 BOM, which Notepad and Out-File leave and JSON.parse rejects, and a file that is empty or NUL-padded, which is what a crash leaves once the metadata reached disk and the data did not. Neither is marked. Verified by mutation rather than by coverage. config-fs.test.ts runs against a real filesystem, because modes, symlinks and rename semantics are the part a mock cannot answer for, and the two e2e cases assert after the app has quit, so they fail when the write guard is removed rather than passing regardless. The other half, checking the values inside a file that parses, is jupyterlab#1101, which sits on top of this. Worked through this with Claude Code. I read the diff, ran the suite and the e2e locally, and checked each new branch by mutating it and confirming exactly one test went red.
The name is <config>.<pid>.tmp, and a pid is guessable. openSync with 'w' follows a symlink at that name, and chownSync is path based and followed it again, after the descriptor was closed. In a directory the user is not the only one who can write to, a single sudo jlab would truncate whatever the link named and then hand it to the config's owner. O_EXCL through 'wx' refuses the name rather than following it. An existing one is either a temporary left by an earlier run with the same pid, or a plant; unlinking removes the link and not its target, and no live process shares the pid, so retrying once is safe. Ownership goes on through the descriptor, which has nothing to follow. A config created for the first time was landing at the umask default, usually 0644. app-data.json holds recentRemoteURLs, whose entries carry a token in the query string, which is the reason this branch carries modes at all; carrying one across and then creating the first one world readable only covered half of it. New config files are created 0600. Encoding is decided by the byte order mark rather than assumed. A UTF-16 file decoded as UTF-8 turns its mark into two replacement characters that neither trimming nor the NUL strip removes, so it was marked corrupt and saving was disabled for the run. Notepad writes one on Save As and PowerShell's Out-File writes one for several encodings, and troubleshoot.md sends people to edit this file by hand. The CLI moved here from jupyterlab#1101, because the change that made save() return a boolean instead of throwing is the one that made every caller print success over a write that did not happen. The dialog stays there; this is just the callers telling the truth. The two e2e cases cleaned up in the wrong block, so a failed assertion in the first one leaked both temp directories, and a launch failure leaked them too. Mutation checked: 'wx' back to 'w', 0600 back to the default, and the UTF-16 branch removed each turn exactly one test red, and each of those tests is against a real filesystem rather than a mock. Worked through this with Claude Code, which found the symlink case. I confirmed the UTF-16 decode by hand before changing anything, and ran the e2e locally.
Two shapes the decode still got wrong, both from review, both verified against Node before changing anything. A file saved as UTF-16 big endian, which Notepad offers as "Unicode big endian" and Out-File as BigEndianUnicode, fell through to a UTF-8 read: its mark became two replacement characters that no trimming removes, JSON.parse failed, and the path was marked. That is the worst outcome available, because a marked file is refused for every write for the rest of the run and the file on disk never changes, so the next launch does it again. Node has no utf16be, so the bytes are swapped in a copy and read as little endian. The little endian case was already handled, which made leaving this one out an asymmetry rather than an omission. NUL was stripped everywhere rather than off the end. The shape jupyterlab#881 reports is padding at the tail, but stripping throughout also closes a hole torn in the middle: `{"a":1,"b":2` + NULs + `}` becomes JSON that parses and holds a value nobody wrote, which the next save then persists as the user's own. Measured both ways; the tail-only form still reads the padded file and refuses the torn one. Both mutation checked through .claude/scripts/mutation-check.mjs, which is new and refuses a pattern that does not match exactly once: each turns one test red and restores the source byte for byte. Not fixed here, and named rather than hidden: with --project-path, a value equal to the global one writes no override, and the CLI still says it set it successfully. handleConfigSetCommand is not exported and has no test, so the branch I wrote for it had no way to be covered; I took it back out rather than ship an untested one at the end of a long session. Worked through this with Claude Code. The two decode claims are node runs, not readings of the diff.
`contents.replace(/\0+$/, '')` is quadratic when the NUL run is followed by anything else: the anchor makes the engine retry from every position in the run. A file torn in the middle is exactly that shape, and this read runs synchronously while the config modules are still being imported, so the wait lands before any window exists, which is the failure jupyterlab#824 is about. Measured through readJsonConfigFile on this branch: 214 ms at 20 KB of interior NULs, 3.2 s at 80 KB, 22.7 s at 200 KB. A scan back from the end is under a millisecond at all three. The existing "refuses JSON torn in the middle" test uses a six byte run, which covers the branch at the one size that hides what it costs, so the new test uses a block-sized run and asserts the wall clock. Put the regex back and that test goes red. The two forms agree on all nine shapes I could think to compare, including the one that matters most, a run in the middle with no trailing NULs, where both keep the NULs and leave the file unparseable. Also here, both from reading the split rather than the code: A stray doc block above decodeConfig described value validators and a sessionconfig.ts dependency that are not on this branch. readJsonConfigFile said a mark is cleared by "a restart, or Reset to Defaults". There is no Reset to Defaults in this tree; that button belongs to the notice, which is the follow-up. resetConfigFile is what does it, and it says so now. One thing I looked into and did not change: recursive mkdirSync through a config directory that is a dangling symlink. I added a guard, and then measured that mkdirSync already throws ENOENT there and creates nothing, on both the direct and the nested path, so the guard was dead and came back out. The test stays as a pin on that behaviour rather than as cover for a check. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Htfy7XC6ZG2kyr1M3tyWcn
It asserted `chownSync` was not called, and the code calls `fchownSync`, so the assertion was about a function this path never reaches. It also stubbed `statSync` while the existing file is found with `lstatSync`, the call the root case beside it already stubs, so it returned on the `!existing` branch before reaching the check it is named after. Two ways of passing for the wrong reason, on the one test standing between a sudo-run save and handing a user's config to root. Removing `process.getuid?.() !== 0` used to leave the whole suite green and now turns this red, and removing the fchown turns it red too, which is what this branch already claims about every new branch in it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Htfy7XC6ZG2kyr1M3tyWcn
Ten lines of comment above a six line function, with a blank separator line the rest of the file does not use: two JSDoc blocks in src/main use one at all, and that one is before @PARAM tags rather than between prose paragraphs. Same content in six lines, still inside the width the surrounding comments keep. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Htfy7XC6ZG2kyr1M3tyWcn
A sentence broken across several // lines is a wrapping decision made for the editor and the diff viewer, which both wrap it anyway, and it turns one idea into four lines a reviewer has to reassemble. Master already reads this way: of its 222 // blocks in src/main, 55% are a single line and 34% are two. Nothing forced the break. Prettier leaves comments alone and there is no max-len rule, checked with 173 and 204 character lines that came back byte for byte. No prose changed, only the line breaks between it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Htfy7XC6ZG2kyr1M3tyWcn
The pass before this rewrapped comments that were already on master, so the diff carried lines the change never touched. Scoped to comments this branch adds, and directives are skipped: folding `@ts-ignore` into the `eslint-disable-next-line` above it stops it applying, which broke the type check on another branch before this was caught. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Htfy7XC6ZG2kyr1M3tyWcn
notluquis
marked this pull request as draft
August 22, 2026 12:18
carryOwnership returned early whenever there was nothing at the path, so `sudo jlab` on a machine with no config created it root:root and every later unprivileged run got EACCES on its own settings. That is the exact lockout the function's own comment says it prevents; it only covered the case where a file was already there to copy from. With no file to copy, the containing directory is what the new one has to match. Where the directory is genuinely root's, as under a sudo that also moved HOME, it already says root:root and nothing changes. The existing root test stubs an existing file, so it never reached this branch. The new one stubs ENOENT on the path and real stats on its directory, and reverting the fix turns exactly it red. Worked through this with Claude Code. I read the failure path, added the test, and mutation-checked it: one test goes red and the source comes back byte-identical. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016CxXVgkyGjZcrtvCa8WudF
Both are cases the read path already meant to cover and did not, found by reproducing the review findings from jupyterlab#1115's own transcript rather than by reading. A NUL-padded file ending in a newline was marked while the same file without the newline was not. The tail trim leaves the newline in place, and `String.trim` does not treat NUL as whitespace, so `'\0\0\0\n'` reached `JSON.parse` and `'\0\0\0'` did not. The emptiness check now counts NUL as nothing. It decides whether there is anything worth protecting, not what to parse, so the deliberate refusal to strip NUL from the middle is untouched: a file torn there still refuses rather than being spliced into valid JSON. A UTF-16 big endian file cut mid-character threw a RangeError out of the decode, because `swap16` needs a whole number of code units, so a truncated file was marked and every write to it refused for the run. The trailing half code unit is dropped and the rest decodes. Three test files also stubbed `chownSync`, which the code has never called; it calls `fchownSync` through the descriptor, which is the whole point of that step. They stub the real one now, and the dead duplicates in `utils.test.ts` are gone. Worked through this with Claude Code. Each of the two reproduces on this branch before the fix, both new tests are mutation-checked, and the full suite is 629 green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016CxXVgkyGjZcrtvCa8WudF
A project override is persisted only when it differs from the user value, and the user value comes from the global settings.json. Marked unreadable, that read yields defaults, so an override that happens to equal a default stops looking like an override and is dropped from a workspace file that was perfectly readable.
Reproduced on this branch before fixing: a global settings.json holding `serverArgs` with a project overriding it back to the default writes `{}` over the project file, and closing the window is enough to trigger it, since both `_disposeSession` and `_setUIMode` call save.
On master this path never ran, because a corrupt global crashed the app during import, which is jupyterlab#824. So this branch introduces it, and it is the same data loss the branch exists to stop. The workspace file's correctness depends on one that could not be read, so the save is refused for the run, exactly as the writer already refuses the marked file itself.
The existing workspace tests could not see it: their fixture makes the global file absent rather than marked, and defaults are the correct baseline when it is genuinely absent.
Found by a code review over this branch and confirmed by reproducing it. Mutation-checked: removing the guard turns exactly the new test red.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016CxXVgkyGjZcrtvCa8WudF
Eleven new save() call sites in cli.ts branch on the boolean, and no test set save() to false, so the whole suite stayed green without running a line of the reporting this branch is about. That is the repository's own lesson: a new branch nothing exercises is not a missing test, it is a branch the suite cannot see. The module mocks made it worse than untested. `UserSettings` was `vi.fn()` with no static `getUserSettingsPath`, and the utils mock omitted `configFileIsUnreadable` and `resolveWorkingDirectory` entirely, so the first test to reach `reportUnsavedSetting` would have died on a TypeError rather than an assertion. They now carry what the code calls. Two tests: a refused write says so and prints no success line, and the read guard points at the unreadable file instead, since only one of those two refusals is something the reader can act on. Both mutation-checked; removing the guard turns one red and removing the call turns two. Found by a code review over this branch. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016CxXVgkyGjZcrtvCa8WudF
The 0600-on-create rule is argued from app-data.json, whose recentRemoteURLs carry a token in the query string, and that argument does not reach `<project>/.jupyter/desktop-settings.json`. The rule was path-agnostic, so it applied there too, where master used the umask default. A project directory shared between two accounts is a real place for that file: a team volume, a container bind-mount with a different uid. The second account gets EACCES reading it, the path is marked, and its workspace settings do not persist for the rest of the session, with only a log line to say so. The mode for a new file is now the caller's to choose and still defaults to 0600, so every path that holds a secret keeps it. WorkspaceSettings passes the umask default, which is what master created that file at. An optional parameter whose default preserves the old behaviour is a branch the suite cannot see, so the test passes it non-default and asserts the mode that lands on disk is the umask default and not 0600. The full suite stayed at 632 green until that test existed, which is the tell. Raised by a code review over this branch as a scope question rather than a defect. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016CxXVgkyGjZcrtvCa8WudF
Six things from a second review pass, five of them consequences of the first pass rather than of the branch as it arrived. The workspace guard added in the last commit returned false in silence. All three GUI callers discard that boolean, so with a corrupt global settings.json, toggling Zen mode in a project wrote nothing, logged nothing and showed nothing. It logs now, and the test asserts the log rather than only the return, because the mutation survived without that assertion. `reportUnsavedSetting` named the wrong file on the project path. A workspace save is refused when the *global* file is unreadable, and the message pointed the reader at the healthy workspace file while withholding the "repair the JSON in it" half, which is the only actionable part. It checks both files now. Reaching that branch meant exporting `handleConfigSetCommand`, which is the only route to it, and completing the mocks: the constructor mock built an object with none of the methods the handler calls, and `resolveWorkingDirectory` was mocked on the wrong module. `umaskFileMode` is gone. Reading the process umask means setting it, so it left the process-wide mask at 0o022 between two adjacent calls, which another thread can observe. A caller now passes 'umask' and the writer simply does not fchmod, letting openSync's mode be narrowed by the mask exactly as master's writeFileSync was. The test sets the mask itself and asserts 0o640 lands, which the old shape could not check. Two JSDoc blocks had been stranded by earlier edits in this branch: the writeJsonConfigFile contract, including the never-throws part that the will-quit caller depends on, sat above `umaskFileMode`, and `trimTrailingNuls`'s measurements sat above `isBlank`. One finding was not real and is recorded here rather than acted on: the report said `refuseSaves()` leaks into later tests through `clearAllMocks`. The file's own `beforeEach` reassigns both saves to `() => true`, so it does not. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016CxXVgkyGjZcrtvCa8WudF
…settings Three from a third review pass, none of them a repeat of the first two. Every new refusal path printed to stderr and left the exit status at 0, so `jlab config set ... && deploy.sh` ran the deploy over a write that never happened. Automation cannot tell that from success, which is the half of "report a refused write instead of printing success over it" the branch did not deliver. `getProjectPathForConfigCommand` is the file's own precedent for a non-zero status on a user-visible refusal. Set through `process.exitCode` rather than `process.exit`, so the handler finishes and the process ends on its own. The file fsync was fatal where the directory fsync beside it is best effort, and that comment already concedes the case: a filesystem that refuses would otherwise log on every save. On a CIFS or gvfs home answering EINVAL or ENOTSUP, the save failed, the will-quit write was refused, and settings were lost every session where master's plain writeFileSync worked. Those two codes mean the filesystem has no fsync, not that the write failed, and are now tolerated. EIO still fails the save, because there the bytes genuinely may not be on disk and publishing the name over the old file would be a lie. The trimTrailingNuls comment read as though the scan below it cost 22.7 s at 200 KB. Those measurements belong to the regex it replaced; the scan is a single backward walk with nothing to backtrack over. A reader would have gone looking for a startup hot spot that is not there. All three mutation-checked. Adding EIO to the tolerated set turns one test red, which is the one that matters: the set is the whole decision. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016CxXVgkyGjZcrtvCa8WudF
The Windows unit leg went red on it. The test asked whether `carryOwnership` gets the directory's stats, and it built the directory name with `path.dirname(path.resolve('/data/fresh.json'))` while the source calls `path.dirname` on the string it was given. On Windows `resolve` turns the POSIX literal into `\data` and the source's `dirname` leaves it `/data`, so the stub never matched and no ownership was carried.
`path` binds win32 or posix when it is imported, from the real host, so this is invisible on macOS and Linux and there is no defect in the application. The expectation now uses the same string the source receives.
Found by CI rather than locally, which is the only place it could be found.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016CxXVgkyGjZcrtvCa8WudF
Round four, and the first one is a bug the third round's own fix introduced. `reportUnsavedSetting` set `process.exitCode = 1` unconditionally, and `addUserSetEnvironment` is not CLI-only: `app.ts` calls it from the `InstallBundledPythonEnv` handler. In the GUI nothing is about to exit, so the status sat on a process that kept running and reported the whole session as a failure to whatever launched it when the user quit hours later. The status is now the caller's to ask for, and the GUI-reachable path declines it. The directory-owner fallback was still a no-op in a case it reads as covering. `writeJsonConfigFile` calls `mkdirSync` twelve lines before it, so under `sudo` a config landing in a directory this function just created had root:root to copy from — `.jupyter` inside a user's project is that case. `mkdirSync` reports the topmost directory it had to create, so those now take the owner of whatever already existed above them, and the file takes it from there. Two corrections rather than changes. The guard on `WorkspaceSettings.save` also blocks `uiMode`, which is exempt from the comparison the guard is argued from, and narrowing it to the comparison-dependent keys would reintroduce the data loss it was added for: `save` rebuilds the whole file, so writing it to persist `uiMode` drops every other override in the same breath. The comment says so now. And `openExclusive`'s EEXIST recovery assumes no live process shares the pid, which a home directory mounted by two machines breaks; noted on jupyterlab#1114 rather than changed here. The new workspace test left the global path marked in module state. `settings.test.ts` and `appdata.test.ts` both carry the `afterEach` for that reason and this one did not. Both code changes mutation-checked. The exit-status one needed its fixture fixed first: with the bundled python present the whole block is skipped, so the assertion was passing without reaching the line it is about. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016CxXVgkyGjZcrtvCa8WudF
notluquis
added a commit
to notluquis/jupyterlab-desktop
that referenced
this pull request
Aug 24, 2026
The bare catch could not tell an absent file from an unreadable one, and the next statement in both callers is a write. Probed on this branch: readable at construction, EBUSY at save time, and `save()` wrote `{"theme":"dark"}` with `futureSetting` gone — the exact loss this branch exists to prevent, silently, with the write reporting success. The reachable triggers are all post-startup, since a malformed file throws out of `read()` at module import before a save is ever reached: EBUSY or EPERM while an antivirus or backup pass holds it on Windows, EACCES after a permission change, EMFILE under descriptor pressure.
Absent stays silent, because merging over nothing is right for it. Anything else is logged. Refusing the write outright is better and is jupyterlab#1115's, whose shared reader already does it; this branch is not the place to add a second mechanism for it.
Two things about the tests rather than the code.
`takes nothing from a file whose top level is not an object` used `[1,2,3]`, which is the only non-object shape that survives `read()`: `null`, a number and a string all throw out of `key in jsonData`. The name claimed the whole class and the fixture covered one member of it, so it is named for the array now.
The two `Object.prototype` assertions in the `__proto__` test are for the merge, not for the read, and the test did not say so. Measured by deleting them: with `read()` mutated to walk the file instead of the enum, the test still goes red with both gone, and the round-trip assertion below is what catches that one. They fire for the other mutation, swapping the spread for `Object.assign`. Both now say which.
Found by a code review over this branch. The new guard is mutation-checked in both directions: never logging and always logging each turn exactly one test red.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016CxXVgkyGjZcrtvCa8WudF
notluquis
added a commit
to notluquis/jupyterlab-desktop
that referenced
this pull request
Aug 24, 2026
Self-review against the rule this repository just wrote down: before adding a side effect to a function that already exists, grep its callers. `readJsonFileOrEmpty` is reached from both `save()` methods, and eighteen call sites in `src/main` reach those, so the log line added in the last commit would have repeated on every settings change for as long as the condition lasted — an antivirus pass holding the file, a permission that stayed wrong. That is the same reason this repository already gives for leaving the directory flush at debug level: raising it would put a line in the log on every single save. Reported once per path per run instead. The set is module state, so it outlives a test the way the unreadable set in jupyterlab#1115 did, and the second unreadable case here read as silent because the first had already reported. `resetUnreadableReports` exists for that and runs in `beforeEach`, which is what `settings.test.ts` and `appdata.test.ts` already do for the sibling set. Mutation-checked: dropping the "not reported yet" half of the condition turns the new test red. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016CxXVgkyGjZcrtvCa8WudF
Two from a fifth review pass, and the first is the third round's fix not working at all. `process.exitCode = 1` does not survive Electron's quit. Measured against the repo's own Electron 42: setting it and calling `app.quit()` exits 0, while the quit event reports 0 and `process.exitCode` still reads 1. `main.ts` takes that path for `config`, so `jlab config set … && deploy.sh` ran the deploy anyway. The test added for it passed only because vitest is plain Node, which is this repository's own definition of a green test proving nothing. It is `process.exit(1)` now, four lines from where `getProjectPathForConfigCommand` already does the same, and the test asserts on `process.exit` rather than on a variable the app never reads. A config symlinked to a volume that is not mounted had its whole missing target tree created on the boot disk, with the settings written into the shadow copy — and on macOS a directory sitting on the mount point blocks the real mount. `mkdirSync` with `recursive` is right for an ordinary path, because that is the first run; a link we followed names somebody else's tree. Skipping it leaves `openExclusive` to fail with ENOENT and the catch to report it, which is what master's `writeFileSync` did. Reproduced against a real filesystem before the fix: the write reported success and the tree was there. Both mutation-checked. Reverting the exit to `exitCode` turns a test red, which it did not before, and restoring the unconditional `mkdirSync` turns the new symlink test red. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016CxXVgkyGjZcrtvCa8WudF
Four from a sixth review pass, and the first is the fourth round's fix doing nothing. The `afterEach` calling `resetConfigFile` was a no-op. The fixture leaves `existsSync` answering true, so the twenty-slot quarantine scan finds every slot taken, logs, returns false, and never clears the mark. `appdata.test.ts` and `settings.test.ts` both set `existsSync` to false before calling it; this one did not, and the hook read as cleanup while doing nothing. There is now a test after it that fails if the reset stops working, rather than the next test somebody appends failing while pointing at itself. `jlab env update-registry` reported a refused `app-data.json` write and exited 0. That handler is CLI-only — `app.ts` imports only `addUserSetEnvironment` and `createPythonEnvironment` — so the reason `setsExitCode: false` exists for the other one does not apply here, and `jlab env update-registry && deploy.sh` ran the deploy over a refresh that never reached disk. `carryOwnership`'s doc block was orphaned again, this time by `carryOwnershipOntoPath` being inserted between the doc and its function. It says the ownership is carried "through the descriptor, since the path form follows a symlink", which is the opposite of what the function it had drifted onto does. Not changed, and recorded in Remaining instead: `WorkspaceSettings.save()` returns true on a path that writes nothing, so `jlab config set --project theme system` prints success when the global already holds that value. The behaviour is master's; the success-reporting contract that makes it wrong is new here. Both code changes mutation-checked, and so is the cleanup hook now that something depends on it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016CxXVgkyGjZcrtvCa8WudF
…r that needed `openExclusive` existed only to survive `EEXIST` on a temporary named `<target>.<pid>.tmp`, and the `wx` comment on it argued that the pid is guessable. Naming the temporary from `randomBytes` removes both: the retry is unreachable and the plant it defended against has nothing to aim at. Eleven lines and a decision point gone, and `wx` stays as depth, because the property is worth keeping even once the odds are not. Two things learned by reading `write-file-atomic`, which was measured as an alternative and rejected for other reasons. `process.pid` is not unique inside a worker thread, so the old name did not actually keep two writers apart, which is why that package hashes the thread id in as well. And a temporary that outlives a hard kill is a real cost of unpredictable naming, where the pid form left at most one per pid; jupyterlab#1114 carries the sweep. Not applied, and measured rather than argued: the review also called the `mode` argument to the open dead work, since `fchmod` follows it. It is not. `openSync(path, 'wx', 0o600)` creates the file at `0600`, while opening at `0o666` gives `0644` under the usual umask and leaves it briefly wider than the file it replaces until the `fchmod` lands. That window is what the argument exists to close. The symlink-plant test went with the name it planted at. It was passing for the wrong reason afterwards, since nothing touches that path any more, and it is replaced by what a real filesystem can still observe: repeated writes leave no temporary behind. That the name is unpredictable is asserted in `utils.test.ts` against the mocked `openSync`, which is the only place it can be seen before the rename takes it away, and reverting to the pid form turns four tests there red. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016CxXVgkyGjZcrtvCa8WudF
`resolveConfigPath`'s fallback resolved exactly one hop. With `settings.json -> mid.json -> real.json` and the payload at the end not yet materialised, the write landed on `mid.json` and turned it from a link into a regular file, which is the outcome the function's own comment says it prevents. Two-hop chains are what GNU Stow and chezmoi produce, and the dangling end is the normal state right after a dotfiles clone. It follows the chain now, with a hop cap so a cycle terminates. A comment still told the reader that `openExclusive` fails with ENOENT. That helper was deleted in the previous commit, so grepping for it found nothing. Not changed, and now in Backwards-incompatible instead: a hard-linked config is detached by the rename, where master's in-place write kept both names updated. Detecting it would mean refusing the write or giving up the atomicity that is the point of the branch, so it is documented rather than handled. Found by a seventh review pass. The chain fix is mutation-checked: capping the walk at one hop turns the new test red. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016CxXVgkyGjZcrtvCa8WudF
…guard honest Three from an eighth review pass, all of them mine. `carryOwnershipOntoPath` used `fs.chownSync(dir, ...)` while running as root, which is the path form that `carryOwnership`'s own comment four lines down says it avoids because it follows a symlink. `mkdirSync` with `recursive` can create several levels and the loop chowned each, so between the mkdir and the chown somebody who can write the parent could swap a directory for a link and have root chown whatever it names. It opens the directory and uses `fchownSync` now, the same shape as its sibling. `resolveConfigPath`'s hop cap returned whichever link it was holding when it ran out, and a cycle is the only way to run out. The write then renamed a regular file over that link and destroyed part of the structure the resolver exists to keep. It returns the original path now, and `writeJsonConfigFile` treats a followed link that resolved to itself as unresolvable and refuses, because returning the path alone still let the rename land on the link. The cleanup in the catch unlinked the temporary even when the open had failed with EEXIST. `wx` is there so an entry already at that name is refused rather than followed, and deleting it anyway undid the guard on the one path where it fired. Unreachable against a real filesystem now that the name is random, so it is pinned with a mocked open. All three mutation-checked. Two tests were asserting the old shapes and are updated: one expected the path form of chown, and the other's mocked `readlinkSync` returned the same target forever, which is an infinite chain rather than the dangling link it was named for. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016CxXVgkyGjZcrtvCa8WudF
`troubleshoot.md` tells a reader with a launch problem to remove settings from `settings.json` and `desktop-settings.json` by hand. This branch changes what happens when that edit leaves invalid JSON: the application starts instead of crashing, which is the fix, and then refuses to write that file for the rest of the run, so every setting changed afterwards is discarded at quit with only a log line to say why. The instruction stays; what follows it now says what to do when it goes wrong. `user-guide.md`'s configuration section describes what each file holds and said nothing about permissions, so the `0600` for the two files that carry a token was undocumented, as was keeping the mode of a file that already exists. It also now says the files are replaced rather than edited in place, and that a symlinked config keeps its link, both of which are visible to anyone who has arranged those files deliberately. No behaviour change; the branch had not touched a markdown file until now. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016CxXVgkyGjZcrtvCa8WudF
…e tests `resetConfigFile` renamed an unresolvable symlink into `.corrupt`, which is the exact case `writeJsonConfigFile` refuses on the same signal from `resolveConfigPath`. With `settings.json -> b.json -> settings.json` the read marks it, the write correctly declines, and the reset took the user's link away and orphaned what it pointed at. It has no production caller on this branch; it becomes reachable when jupyterlab#1101 wires the dialog to it, which is why it is worth fixing before then rather than after. Two test branches were unreachable rather than untested. The `WorkspaceSettings` mock pinned `save` to `false`, so the success half of `config set --project` never ran and the suite stayed green whichever way it went; the flag moves through `vi.hoisted`, since a `vi.mock` factory runs above every `const`. And `handleConfigUnsetCommand` was not exported the way its sibling was, so its own `saved` branch could not be reached from a test at all. Both halves are covered now, and the mock carries `unsetValue`, which the handler calls and the mock did not have. Found by a tenth review pass, which returned two findings against ten in the pass before it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016CxXVgkyGjZcrtvCa8WudF
notluquis
added a commit
to notluquis/jupyterlab-desktop
that referenced
this pull request
Aug 24, 2026
The comment said an EBUSY or EACCES would fail the `writeFileSync` as well, so writing after a failed read was safe. That is false, and a review falsified it with real files: a `settings.json` at mode 0200 gives EACCES on the read and OK on the write, because `writeFileSync` opens O_WRONLY and never reads. Reproduced here before changing anything. The file ended up as `{}` with one log line, which is exactly the loss this branch exists to prevent.
The reader now returns `undefined` for a file that is there and unusable, and `{}` only for one that is absent, since merging over nothing is right for that. Both saves return without writing on `undefined`. Refusing was already the stated better answer and was being left to jupyterlab#1115's shared reader; it turns out this branch cannot wait for it, because the case it was deferring is the case that loses the data.
The messages say the file is left alone until it is repaired rather than that keys may be dropped, which is what they now describe.
Found by an eighth review pass. Mutation-checked: removing the refusal in `UserSettings.save` turns one test red.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016CxXVgkyGjZcrtvCa8WudF
Routing it through `settingsFilePathFor` introduced a regression that the previous round's note got wrong. That helper calls `resolveWorkingDirectory`, whose `lstatSync` rejects a symlinked project directory and substitutes `$HOME`, while `getProjectPathForConfigCommand` validated the same argument with `statSync`, which follows the link. Measured: `statSync(dir).isDirectory()` is true and `lstatSync(dir).isDirectory()` is false for a symlink to a directory. The previous round recorded this as `open-file` regressing to agree with `list` and `set`. That was wrong, and a review measured it: on master `set` already went through `new WorkspaceSettings(projectPath)`, which resolves internally, and `list` builds its own path. Only `open-file` used the unresolved argument, so nothing was made consistent and one command was broken. The consequence is also worse than the Remaining row said. It is not the "Settings file does not exist!" message: `$HOME` is the default working directory, so its workspace file usually exists, and `shell.openPath` silently opens a different project's settings for the user to hand-edit. It builds the path from the unresolved argument again, as master did. The `lstatSync` itself stays jupyterlab#1114's. The two e2e assertions filtered for any name ending in `.tmp`, which any temporary in the user data directory satisfies and which a temporary from a different writer would also pass. They match this writer's name now. Found by an eleventh review pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016CxXVgkyGjZcrtvCa8WudF
The JSDoc says this reports failure rather than throwing, because `will-quit` calls it between `preventDefault` and `quit`, and a comment a few lines down makes the same point about a throwing getter. The temporary name was built one line above that try. `randomBytes` throws when the entropy source fails or is unavailable, and the throw would escape through `ApplicationData.save()` into the `will-quit` listener, leaving `_quit()` unreached and the app unquittable, which is the exact failure the contract exists to prevent. Two lines: declared before the try, assigned inside. The cleanup is already gated on `created`, which cannot be true before the assignment. The test needed `crypto` mocked rather than reassigned, because `randomBytes` is a named import and its binding is fixed at import time, and the flag is hoisted because a `vi.mock` factory runs above every `const`. Found by a twelfth review pass, which returned this one finding. Mutation-checked: moving the assignment back outside the try turns it red. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016CxXVgkyGjZcrtvCa8WudF
The previous commit said the change was mutation-checked. It was not: the shell ate the `${}` in the pattern file, `mutation-check` reported that the pattern matched zero times, and the message went out anyway. Written from a file this time, and it passes: moving the assignment back outside the try turns exactly one test red.
The comment added there was also hand-wrapped.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016CxXVgkyGjZcrtvCa8WudF
notluquis
added a commit
to notluquis/jupyterlab-desktop
that referenced
this pull request
Aug 24, 2026
The refusal added last round was invisible: `save()` returned `void`, so `jlab config set theme dark` printed "set successfully" over a write that never happened. That silent path is one this branch created, so it is this branch's. Both saves return a boolean now and `config set` asks before it claims anything. The other sixteen call sites belong to jupyterlab#1115, which changes the same signature. Two comments still described the behaviour from before that round. The catch block claimed EBUSY and EACCES "throw rather than lose quietly", which the measurement that motivated the change disproves: mode 0200 gives EACCES on the read and OK on the write. And `reportRejected` was typed as returning an object while returning `undefined`, which only `strict: false` let through. Found by a ninth review pass, which noted these are one reconciliation rather than three defects. Mutation-checked: returning true from the refusal turns the new test red. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016CxXVgkyGjZcrtvCa8WudF
writeJsonConfigFile had reached 95 lines carrying fourteen concerns, six of them decisions about this app's config files and eight of them what any atomic writer does. Counted the same way, write-file-atomic carries seven in 269 lines and all seven are the second kind. The rounds of review it kept drawing were getting quieter, 3 then 2 then 2 then 1, while the function grew 27%, so the count was not the thing to read. Now the policy decides and the mechanism publishes: which files are refused, how a symlink resolves, whether a new file is 0600 or takes the umask, the JSON, the parent directory and the ownership carry-over stay in writeJsonConfigFile at 58 lines; the unpredictable temporary, the wx open, fchmod, fsync, the close ordering, rename, directory sync and cleanup move to writeFileAtomicSync at 54. It throws once it has removed what it created, so the caller still decides what a failed save means and the reported behaviour is unchanged. Behaviour-preserving: all 648 existing tests pass untouched. Mutating the wx flag, the rethrow and the temporary cleanup each turns tests red. Mutating the close ordering did not, which is a real gap the branch introduced along with that line, so there is now a test that the descriptor is not closed twice when the close itself fails; that mutation turns it red too. Worked through this with Claude Code. I read the diff, ran the suite and the mutations locally, and checked the two halves against write-file-atomic's source rather than its README.
…not fail Three findings from a review pass over the split, all confirmed against the tree first. The mode was read off whatever `lstat` answered, with no `isFile` check, so a directory at the config path donated its 0755 to the temporary. The suite already reached that: `config-fs.test.ts` puts a directory at `app-data.json`, whose `recentRemoteURLs` entries carry a token in the query string, and the temporary holding them was created world-readable until the rename failed and the unlink ran. It now falls through to the 0600 default. `leaves a temporary belonging to another process alone` planted `settings.json.999999.tmp`, a name nothing can choose since 8039df1 made the suffix random rather than the pid, and its comment described that removed scheme. The property underneath is real and worth keeping, that the cleanup removes the one name it created and does not sweep the directory, so the comment now says that instead; mutating the cleanup into a sweep turns it red. The `fs` mock stubbed `chownSync`, which nothing in `utils.ts` calls; ownership goes through `fchownSync` everywhere. A comment three hundred lines down in the same file records an assertion that passed on that stub regardless of what the source did, so the stub is gone. The new test for the `isFile` guard passed at first for the wrong reason: it mocked `statSync`, which only the symlink path reads, so it was passing on the 0600 default rather than on the guard. The mutation caught it, and it mocks `lstatSync` now. 650 tests. Worked through this with Claude Code; I confirmed each finding against the tree before touching it, and mutated the `isFile` guard and the cleanup to check both new tests can actually go red.
…ting `config open-file` built its path from the raw `--project-path`, while `config list` prints `settingsFilePathFor` and `config set` writes through `new WorkspaceSettings`, both of which resolve. `WorkspaceSettings` resolves in its own constructor, so the resolved path is the file the app actually loads: for a symlinked project directory the command was naming a file nothing reads and offering it up to be hand-edited. It now goes through the same helper. `resolveWorkingDirectory`'s `lstatSync` is still what collapses that directory to $HOME and is still jupyterlab#1114's; while it does, all three commands are wrong about one file rather than each about a different one. `process.exit` immediately after `console.error` loses the message. Measured on this repo's Node with a stderr pipe nobody drains: a line behind 200 KB of output arrives as 65536 bytes, one pipe buffer, with the line itself gone while the non-zero status still lands, so `jlab config set ... || echo failed` reports a failure naming nothing. `fs.writeSync(2, ...)` does not fix it, measured the same way, because the queued write is still ahead of it; exiting from an empty write's callback does, and all 200036 bytes arrive. The two sites this branch created use that now. `getProjectPathForConfigCommand` has the same shape and predates the branch, so it is left alone. That makes the exit asynchronous, and the six callers all `return` straight after, so nothing runs that did not before. The existing assertions had to wait a tick, and a new test pins the difference: a plain `process.exit` satisfies every other assertion in that block and fails only this one. `launchWith` in the e2e helper wired `cleanup()` to a throw from `electron.launch` alone, so a throw from `firstWindow()` or from the `stubAllDialogs` retry left the Electron process alive and both temporary directories on disk. The whole sequence is inside the try now, and the catch kills the process rather than closing it, since a close needs the connection that may have just failed. 652 tests. Worked through this with Claude Code; I measured the pipe behaviour myself before picking the fix, and mutated the resolution and the flush to check each new test can go red.
CI went red on ubuntu and macOS where the local run was green, and the message was not a failed assertion: `process.exit unexpectedly called with "1"`, an unhandled error, while all 652 tests still passed. Deferring the exit to a stderr write callback moved it out of the window the test's stub covers. Three tests in that block assert only on the message and never wait for the flush, so their exits arrived after `afterEach` had restored `process.exit`, and the real one ran. It only showed up on CI because stderr there is a pipe: against a terminal the callback lands early enough to fall inside the test. Reproduced locally with `2>&1 | cat`, which is the whole difference. `afterEach` now waits before restoring, so this holds for tests written later that do not know they need to, and the wait is a write queued behind the code's own rather than a `setImmediate`: stderr writes are ordered, so it cannot run first, which `setImmediate` only happened to do on a terminal. Note for whoever runs the mutation checker here: it reads `Tests N failed`, and an unhandled error increments nothing, so this class of break reads to it as a mutation the tests survived. Verified by hand instead, restoring the synchronous `afterEach` and confirming the error comes back. 652 tests, run with both streams piped. Checked with Claude Code alongside me.
…xit back out Two findings from a review pass, both about what this branch itself added. `carryOwnershipOntoPath` opened each directory with `fs.openSync(dir, 'r')` and its comment said a descriptor was the guard against a swapped-in symlink. It is not: measured, a plain `'r'` open on a link to a directory returns a descriptor whose `fstat` is the target's inode, while the same open with `O_NOFOLLOW|O_DIRECTORY` refuses. `carryOwnership` is safe for a different reason, its `O_CREAT|O_EXCL` open, and the reasoning was copied here without the thing that made it true. Under `sudo jlab config set --project <dir>` over a directory a local user can write, that user could replace the just-created `.jupyter` with a link and have root `fchown` whatever it named. The flags are added, and the assertion no longer stops at `fchownSync` having been called, because that passed with or without them. The deferred exit added two commits ago is reverted. It was measured under plain Node, and this ships inside Electron, where `main.ts` calls `app.quit()` in the same `then`. Measured against this repo's electron 42, stderr on a pipe nobody drains, five runs each: short output and 60 KB deliver the message and exit 1 either way, while at 200 KB and above the deferred form exits **0** five times out of five, losing the status as well as the message, where the synchronous one keeps the status every time. So it bought nothing in the realistic case and gave up the half automation reads in the case it existed for. The truncation above that size is real on both and is now in Remaining. That is the same trap this file already names one screen further up, about `process.exitCode`: a test for it passes under vitest, which is plain Node, and proves nothing about the app. It caught the replacement too, and the note now sits where the decision is so the next person does not re-derive it. 651 tests. Worked through this with Claude Code. I reproduced the symlink follow and the Electron exit table myself before changing either one, and my first Electron run showed no race at all because I had sent stderr to /dev/null, which is a file and applies no back-pressure.
Windows went red while ubuntu and macOS passed: `expected +0 to be undefined`. `O_NOFOLLOW` is POSIX and `fs.constants` has no such key on Windows, so `f & fsConstants.O_NOFOLLOW` is 0 and the assertion compares it against undefined. The source is unreachable there, since `carryOwnershipOntoPath` returns unless `getuid()` is 0 and Windows has no `getuid`, but the test forces `getuid` and reaches it anyway. Split into its own `it.skipIf` rather than wrapped in an `if`, so the run says which platform did not check it. A silent conditional would leave a third of the matrix reporting a pass on an assertion it never made, which is the same shape as the two tests this branch already had to fix for not being able to fail. 652 tests locally, and the mutation that drops the flags still turns one red. Checked with Claude Code alongside me.
notluquis
added a commit
to notluquis/jupyterlab-desktop
that referenced
this pull request
Aug 25, 2026
A review pass read the new reader as protecting a corrupt settings.json in general. It does not: `read()` still calls `JSON.parse` with no try/catch, and `userSettings` is constructed at module import, so a file edited into invalid JSON while the app is closed throws before `app.whenReady` and the app does not start. Only the mid-run edit reaches the catch and gets the file left alone. The comment on the reader was already scoped to "while the app runs" and stayed accurate; what was missing was anything at the parse itself, which is where a reader forms the wrong impression. Guarding it is jupyterlab#1115, which replaces both call sites with a shared reader, and that is now said where the gap is rather than only in the pull request body. Two of the pass's three findings described a head two commits behind: `save()` does return a boolean, `reportRejected` is declared `: undefined`, and both `config set` and `config unset` check the result. Verified against the pushed tree rather than assumed. Worked through this with Claude Code, and I checked each claim against `git show` on the pushed head before acting on it.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
References
SyntaxErrorhalf of An error pops up:A javaScript error occured in the main process#431read()andsave()and conflicts with this (measured withgit merge-tree); landing this first is the cheaper order. Config reads and writes: four things #1101 leaves open #1114 and A config value the reader rejects is deleted from the file at the next save #1116 carry what neither attemptsCode changes
The app was writing the corrupt files it then could not start from. All three config writes went straight at the destination with
fs.writeFileSync, which truncates first and writes second, and one caller is thewill-quithandler, the moment the OS is most likely to kill the process.Writing is now a mechanism helper under a policy one, following Ensuring data reaches disk: temporary in the same directory,
fsync, rename,fsyncthe directory. That last step is the one VS Code,write-file-atomicandatomicallyall skip, and it is what makes the new name survive a power cut.realpathfirst so a config symlinked into a dotfiles repo keeps both its link and its atomicity.openSyncwith'wx', because'w'follows a symlink planted at<config>.<pid>.tmpand a pid is guessable, which undersudo jlabtruncates whatever it names;fchmod/fchownthrough the descriptor for the same reason.0600when there is nothing to carry, sinceapp-data.jsonholdsrecentRemoteURLsand those carry a token. It returnsfalseand never throws, becausewill-quitcalls it betweenpreventDefault()and_quit().Ownership is carried from the containing directory when there is no file to copy from, so a config file that appears for the first time under
sudomatches the directory it lands in rather than being created root:root and locking every later unprivileged run out of it. This covers the case where the user data directory already exists and only the file is missing, which is what happens when a config is deleted or when a new one is written for the first time. It does not cover a genuinely first-ever run undersudo:getUserDataDircreates that directory itself, so it is root-owned before the writer sees it and the fallback reads root:root. Carrying the owner onto the directory belongs with that function and is listed under Remaining.Reading no longer throws. Both
read()methods parsed with no guard from constructors that run during module import, so the throw landed beforeapp.whenReadywith nothing to soften it. An unreadable file is now left in place, marked, and writes to it refused for the run: VS Code's rule rather thanelectron-store's clear-and-continue, becausetroubleshoot.mdtells users to hand-editsettings.json.Not treated as corruption: a byte order mark (Notepad,
Out-File), and empty or NUL-padded files, which is #881 and also what the old non-atomic write left behind.User-facing changes
jlab config set/unset,jlab env set-*andjlab env update-registryreport a refused write instead of printing success over it, and exit non-zero, sojlab config set … && deploy.shdoes not run the deploy over a write that never happened.jlab env createis the exception and reports without a status, because the function it goes through is also reached from the GUI, where an exit would take the running app down. That split is in Remaining.0600where it holds a secret, which is the app's own directory; a project's.jupyter/desktop-settings.jsonkeeps the umask default master used, since a project directory shared between two accounts is a real place for it.jlab config/jlab envpaths report it and exit non-zero; the twelve GUI callers inapp.ts,sessionwindow.tsandwelcomeview.tsdiscard the boolean. This is the deliberate cost of starting at all instead of throwing during import, and it is the state most people who hit Unexpected token ' #824 will land in, so it is a call worth making explicitly: the shared way to surface it belongs with the flag-on-the-object change on Config reads and writes: four things #1101 leaves open #1114, not in a branch this size. Warn when a config file could not be read, and ignore values of the wrong type #1101's dialog is not it either, since that fires once at startup rather than per save.Backwards-incompatible changes
With an unreadable config, settings changed that session are not persisted until it is repaired. Before they were persisted by overwriting the file with defaults, which is the data loss this exists to stop.
A config directory that is not writable now fails the save, where master's in-place write succeeded (measured both sides, uid 501,
0555directory holding a0644file). Creating the sibling temporary needs directory write permission. VS Code,write-file-atomicandatomicallyall fail in this case too rather than degrade, so this follows them, but on this branch alone the failure only reaches the log; the notice is #1101.A hard-linked config is detached.
renamereplaces the directory entry, so the target inode is dropped: asettings.jsonhard-linked into a dotfiles repo starts atnlink 2, and after one save the config isnlink 1with the new content while the other name is frozen at the old one, silently. Measured on both sides; master's in-placewriteFileSynckeptnlink 2and updated both. Detecting it would mean either refusing the write or giving up the atomicity this exists for, so it is written down rather than handled. Symlinks are handled, including chains.Shape
writeJsonConfigFilehad reached ninety-five lines carrying fourteen concerns. Counted the same way,write-file-atomiccarries seven in two hundred and sixty-nine, and all seven are mechanism: it holds no refusal policy, does no JSON, and takes the mode and owner as arguments rather than deciding them.Six of the fourteen were the first kind and eight the second, so they are now two functions along that line.
writeJsonConfigFiledecides, at fifty-eight lines: which files are refused, how a symlink resolves, whether a new file is0600or takes the umask, the JSON, the parent directory, the ownership carry-over.writeFileAtomicSyncpublishes, at fifty-four: the unpredictable temporary, thewxopen,fchmod,fsync, the close ordering,rename, the directory sync, and removing what it created. It throws once it has cleaned up, so the caller still decides what a failed save means and nothing observable changed.What made the split worth doing rather than another pass: the review rounds were getting quieter, three then two then two then one, while the function grew twenty-seven per cent, so the count was not the thing to read. The pass before last returned three findings and all three were interactions between things earlier passes had added.
All 648 existing tests pass untouched, which is what says the behaviour is the same. Mutating the
wxflag, the rethrow and the temporary cleanup each turns tests red. Mutating the close ordering did not, so there is now a test that the descriptor is not closed twice when the close itself fails.Manual testing
macOS, fourteen corrupt shapes through the real Electron runtime: all fourteen start, none leaves a temporary, the file is unchanged. Six kill the process on
master, and whatmasterdoes is a throw during load rather than a slow start:which is the title of #881. Both read paths are covered and they are not the same one: #881 names
app-data.jsonat zero bytes, #824 showssettings.json.Also run on
windows-latest:config-fs.test.tsruns 12 of 20 there, skipping the symlink, mode and umask cases, so Windows covers the write and the recovery and none of the ownership work. Since #1112 merged the unit job runs on all three platforms here as well.Every new branch mutation-checked: each turns exactly one test red, source restores byte for byte. That includes the directory-owner fallback, whose test the old code could not fail, because the existing root test stubs a file that already exists and so never reached the branch.
Four rounds of fixes came out of a code review over this branch, each reproduced here before being changed: the workspace data loss above, the two torn shapes that were being marked, and the reporting path in
cli.tsthat no test could reach because the module mocks would have thrown before the first assertion.Not done: none of the Review guidance checks were run on Windows or Linux. The ownership behaviour is covered by unit tests with
getuidstubbed rather than by a real root run.Remaining
fsyncF_FULLFSYNC, which Node does not exposewill-quitsaves both files, and each now does a filefsyncand a directoryfsyncsynchronously, so four blocking syncs where master had two page-cache writes. Visible on a spinning disk or a network home, and on macOS it is latency paid without the durability, per the row above. Accepted: the alternative is the truncated file #824 is made ofMoveFileExW; Microsoft recommendsReplaceFile, which preserves ACLs and fails less against a process holding the target<name>.<pid>.tmp. A sweep would let a second instance delete the first's in-flight file. #11140444settings.jsonis now replaced wherewriteFileSyncfailed. #1114getUserDataDircreates it, so a first-ever run undersudoleaves the directory root-owned before any config is written and the file's own fallback has nothing user-owned to copy. #1114resetConfigFileandgetUnreadableConfigFileshave no production caller here; the notice that uses them is #1101, so on this branch alone a marked file stays marked for the run. #1114WorkspaceSettings.read()callssuper.read(), so every new session window re-readssettings.json. Once it is marked, those constructions keep their defaults and a new window launches on the default theme andpythonPathwhile the singleton still holds the real values. The write side is guarded, the read side is not, and this is where master threw instead. #1114. The reproducible case is a file that was fine at import and is edited into invalid JSON while the app runs: the next window'ssuper.read()fails, marks the path, and that window alone falls back to defaults while theuserSettingssingleton still holds the real ones, so two windows in one session disagree about the theme and the Python path. Corruption already present at startup does not show it, because the singleton got defaults toojlab env createexits 0 on a refused writecreatePythonEnvironment, whichapp.tsalso imports, and the exit is switched off for that reason. Separating the two entry points is the fix. #1114WorkspaceSettings.save()returns true when the merge produced nothing and no file exists, sojlab config set --project theme systemprints success and exits 0 while the value stays pinned to whatever the global holds. master's behaviour; the contract that makes it wrong is this branch's. #1114EIOorESTALEon a network home marks the path exactly as a parse failure does, with no retry. #1114save()returns a boolean and the eleven callers incli.tscheck it, which is the non-zero exit above; the twelve inapp.ts,sessionwindow.tsandwelcomeview.tsdiscard it, so a Zen toggle against a markedsettings.jsonapplies to the window and is gone at quit with nothing said. Reachable only because this branch lets the app start on that file at all, and not answered by #1101, whose dialog fires once at startup rather than per save. Detail and the shape of the fix are on #1114, with the flag-on-the-object change it belongs toprocess.exitdrops a write still queued on a pipe. Measured against this repo's electron 42 with stderr on a pipe nobody drains: short output and 60 KB deliver the message and exit 1, at 200 KB and above the message is gone while the status survives. A deferred exit from the write callback was tried and reverted, because under Electronmain.tscallsapp.quit()in the samethenand the deferred form exits 0 five runs out of five, giving up the status too. The realisticjlab config setpath is well under the threshold;jlab env update-registryis the one that could reach it, sincenew Registry()putselectron-logoutput on stderr firstcarryOwnershipOntoPathnow opens withO_NOFOLLOW, but themkdirSync(parent, { recursive: true })above it resolves components normally, so undersudoa link planted before the call has the tree built through it. Closing that needsmkdiratwalking one component at a time against directory descriptors, which Node does not expose, so it is written down rather than half-doneAI usage