Skip to content

Separate the atomic writer from the config policy it carries #1124

Description

@notluquis

writeJsonConfigFile in src/main/utils.ts carries nine responsibilities in seventy-five lines of code. Splitting the mechanism from the policy is about forty lines moved inside the same file, adds no dependency and changes no behaviour. Whether it is worth doing is the decision here.

Depends on #1115 landing first, since that is where the function reaches this shape. #1114 is the neighbour and pushes the same way for one of the nine.

What it carries today

mechanism policy
an unpredictable temporary name refuse when the path is marked unreadable
exclusive open, so an entry already at that name is not followed serialise the value to JSON
fsync, tolerating ENOTSUP and EINVAL choose 0600 or the umask default by whether the file carries a token
rename, then fsync of the directory resolve the config's symlink, and refuse a cycle
remove the temporary only when this call created it

Carrying the existing mode and owner sits between the two: it is mechanism, but which mode and owner is policy, which is why write-file-atomic takes both as arguments rather than deciding them.

Why it is worth naming rather than leaving

Counted by the same marker sweep on both sides, write-file-atomic carries seven responsibilities in 269 lines and writeJsonConfigFile nine in 75. Seven against nine is not the point. All seven of theirs are mechanism, and two are ones this repository does not have at all, cleanup on exit and serialising writes to the same path. They hold no policy: no refusal, no JSON, no choice of mode.

The difference showed up in review rather than in reading. Eight passes over #1115 returned 43 findings, and the last pass returned the largest set of the eight. Its three findings were all interactions between things earlier passes had added, not defects in any one of them:

  • mkdirSync with the ownership carry: the directory created a moment earlier is root's, so the fallback that looks for an owner reads root and copies it onto the file.
  • The symlink hop cap with the rename: exhausting the cap returned a link, and the write then renamed a regular file over it.
  • The temporary cleanup with the exclusive open: the open refuses a name already taken, and the cleanup deleted it anyway.

While a finding says this does X wrong, more review converges, because there is a finite list and each fix removes one. When it says these two things interact wrong, the pairs grow with the square of the responsibilities, and each fix adds one more that can pair with the rest.

The shape

writeFileAtomic(targetPath, contents, { mode?, owner? }): boolean
writeJsonConfigFile(filePath, data, newFile): boolean

The second reads the mark, resolves the link and refuses when it cannot, decides the mode, finds the owner, serialises, and calls the first. The line falls exactly between the two columns above.

Two of the three interactions above stop being possible: the writer receives a resolved path and an owner rather than deriving them next to the code that creates directories. The third stays inside the writer, which is where it belongs, in twenty lines rather than seventy-five.

What it does not do

Nothing in #1114 is repaired by this. The refusal stays keyed by path, getUserDataDir still creates its directory root-owned under sudo, and a hard-linked config is still detached by the rename. This is about which function holds what, not about what any of them does.

The other option

Leave it. Nine responsibilities in seventy-five lines is not unusual, the function is covered by 645 unit tests including a mutation check per branch, and the interactions above are now fixed and pinned. The cost of the split is a reviewer re-reading a file they have already reviewed eight times.

I have a bias toward doing it, which is why the measurement is here rather than a recommendation. Worked through with Claude Code; the counts on both sides are from the same marker sweep, and the review numbers are from the runs on #1115 rather than recalled.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions