Skip to content

Delete the host scaffolding that guards nothing - #518

Open
Pixnop wants to merge 6 commits into
devfrom
refactor/487-host-scaffolding
Open

Pixnop wants to merge 6 commits into
devfrom
refactor/487-host-scaffolding

Conversation

@Pixnop

@Pixnop Pixnop commented Sep 20, 2026

Copy link
Copy Markdown
Contributor

All four findings of #487, one commit each, in the issue's suggested order.

What changes

1. logManager.logMessage indexes the logger instead of switching on it (src/utils/logManager.ts, 21 lines removed, 6 added). The five members of ErrorTypes are electron-log's own method names, so the switch spelled out the mapping it was indexing. Logger[mode]?.(redactSensitiveText(message)) replaces it. The optional call is the old default: branch: a mode that slipped past the type reads as an absent property and the line is dropped rather than throwing inside a log call. The redaction is untouched.

2. The duplicated gameVersionId arms collapse (src/ipc/validation.ts, 7 lines removed, 4 added). Two of the four arms were the same expression, left over from the fix that made an invalid id throw instead of being dropped. One undefined test now decides whether the key is written; the value is null or whatever assertString accepts.

3. configManager writes the normalized config as it stands (src/config/configManager.ts, the whole writeConfig wrapper removed). It used to run the document through JSON.parse(JSON.stringify(...)) to drop underscore keys, which cost a second full serialise of installations, versions, backups, icons and accounts on every save, since writeJsonAtomic stringifies too. The only value that ever reaches the writer is a normalizeConfig result, and that builds a fixed object literal field by field, so the renderer's session-only markers are already gone by then. The guard predates the normaliser (b284266, when saveConfig wrote the incoming config straight to disk).

4. The chmod walk runs on the main thread (src/ipc/workers/changePermsWorker.ts deleted, src/ipc/workers/permissions.ts moved to src/ipc/permissions.ts). CHANGE_PERMS spun a worker thread for existsSync/lstat/readdir/chmod over a folder tree. That is I/O, not CPU: the other four workers stream or decode and belong in a thread, this one paid for a worker script, a ?modulePath import, two table entries (one of them 0, with a comment saying pooling bought nothing) and the whole runTrackedWorker message protocol, for one call fired once per Linux install. changePermissions is now async over node:fs/promises, so the delegating nodeFileSystem object goes with it.

The existsSync test in front of each lstat does not simply go away, and the first revision of this branch was wrong about that. existsSync follows links; lstat does not. For a missing path the two agree and the entry is skipped either way, but for a link with nothing reachable behind it they disagree, and folding them into one lstat turned a skip into a refusal. The walk now asks access about a link before refusing it, which is the question existsSync was answering, and refuses only a link that resolves to something. See the Check section below.

Two things the issue flagged as costs came back rather than being dropped. The worker's ten minute bound returns as CHANGE_PERMS_TIMEOUT_MS fed to AbortSignal.timeout, checked before each entry and raced against the walk as a whole, so it holds whether the walk is running or wedged on a syscall. And the handler still reports the same two error texts the pooled worker reported (Changing permissions failed, CHANGE_PERMS timed out), with the reason behind them now logged at debug instead of being dropped on the way out of the worker.

Check

Two findings on the first revision of item 4, both in the chmod walk, both now covered by a test that fails without the fix.

Dropping existsSync turned a skipped link into a failed install. existsSync is an access(F_OK), so it resolves links: a link whose target could not be reached answered false and the entry was skipped before lstatSync ever ran. A single lstat stats the link itself, reports isSymbolicLink(), and the walk refuses it. The class is every link existsSync could not resolve: a dangling target, a symlink loop, a target behind a folder with no execute bit. pathsHandlers.ts:676 turns the refusal into Changing permissions failed and TaskManagerContext.tsx:411 fails the extract task on it by design, so a Linux install into a folder holding one stale link failed where it used to complete. The link is now tested with access and refused only when something is reachable behind it (src/ipc/permissions.ts:69-82). Nothing is applied on either branch, so the gap between the two answers leaves nothing for the tree to change under. Regression test: tests/ipc/permissions.test.ts:140.

That also explains why the suite stayed green. The handler case pinning the refusal built its link over a file it never wrote (tests/ipc/pathsHandlers.test.ts:1027), so it was passing on the dangling arm rather than the live one it is named for. It now writes the target first, the way the case in permissions.test.ts always did.

The ten minute bound did not cover the case its own comment names. AbortSignal.timeout was only ever observed at signal?.throwIfAborted(), which runs between entries. A syscall that never settles never reaches the next check, so the promise stayed pending for good and the handler never rejected: the task went on showing as running and the quit guard stayed armed. A hard NFS or FUSE mount that goes away is exactly that, and it is the case the comment at pathsHandlers.ts:70-76 claims to cover. The bound the worker carried never depended on the worker's state either; it was a setTimeout on the main thread, which is why the thread it gave up on was discarded rather than reused. The signal is now raced against the walk (src/ipc/permissions.ts:95-111), so the call settles when the signal fires whatever the walk is doing, and the walk is abandoned in the same way the worker was. The check between entries stays: it is what stops a walk still making progress from touching the rest of the tree. Regression test: tests/ipc/permissions.test.ts:206.

What stays

Nothing was skipped. Item 4 is the one the issue rated least free, and it is the one that carried real cost: most of its diff is the test port, not the saving.

Untouched on purpose: the redaction in logMessage, the symlink refusal and the 100,000 entry cap in changePermissions, the three outcomes of the gameVersionId check, the normaliser's fixed-literal shape, the path policy, the IPC validation at the boundary and the accessibility wiring. tests/security-boundaries.test.ts, tests/log-provenance.test.ts, tests/text-contrast.test.ts and tests/i18n/i18n-parity.test.ts are unchanged and green.

serveTasks keeps its promise-or-value handling even though the synchronous handler that motivated it is gone. It is an await either way, so there is nothing to remove, and tests/ipc/workerHost.test.ts still covers the path.

Behaviour

No test was deleted. Four files changed, all because the code under them changed:

  • tests/ipc/configManager.test.ts, the underscore-fields case now asserts the invariant on normalizeConfig as well as on the file, so it fails where the invariant actually lives rather than only at the writer that no longer enforces it.
  • tests/ipc/permissions.test.ts ported to async, same cases, same assertions, plus three new ones: the abort signal, a link with nothing reachable behind it, and a syscall that never answers. The symlink refusal and the entry cap did not move.
  • tests/ipc/pathsHandlers.test.ts, the CHANGE_PERMS block drove a fake worker that no longer exists. It now drives the real walk against the test's temporary tree: mode read back off disk, acquireWorker asserted never called, and a second case pinning the fixed error text the renderer's extract task sees, over a link whose target is now written first.
  • tests/ipc/pathsHandlersWin32.test.ts, the worker mock removed and the case retitled. The assertion (false before anything runs, no worker acquired) is unchanged.

Callers checked. pendingConfig is assigned in exactly one place, from normalizeConfig. logMessage's only untyped entry point is the LOG_MESSAGE channel, which filters the mode against the same five strings first. The gameVersionId correlation check in gameHandlers.ts still gets undefined and null as two different answers. changePermissions has one caller, the CHANGE_PERMS handler, and changePerms has one renderer call site, in TaskManagerContext's extract task, which awaits it and fails the task on a rejection.

Gate against origin/dev (5ddf93a):

dev here
test files 242 passed 242 passed
tests 4461 passed, 2 skipped 4465 passed, 2 skipped
statements 94.58% 94.61%
branches 91.02% 91.00%
functions 95.22% 95.32%
lines 96.26% 96.32%

npm run typecheck clean on all three projects, npm run lint:ci 0 errors and the same 14 pre-existing warnings, npm run format:check clean.

Closes #487. Part of #492.

The five modes in ErrorTypes are electron-log's own method names, so the
switch spelled out the mapping it was indexing. The optional call keeps the
old default branch: an unknown mode reads as an absent property and the line
is dropped rather than throwing. The redaction is unchanged.
Two of the four arms were the same expression, left over from the fix that
made an invalid id throw instead of being dropped. One undefined test decides
whether the key is written, and the value is null or whatever assertString
accepts. The three outcomes the boundary owes its callers are unchanged.
writeConfig ran the whole document through JSON.parse(JSON.stringify(...)) to
drop underscore keys, which cost a second full serialise of installations,
versions, backups, icons and accounts on every save. The only value that ever
reaches the writer is a normalizeConfig result, and that builds a fixed object
literal field by field, so the renderer's session-only markers are already gone
by then. The guard predates the normaliser.

The configManager test now asserts the invariant on normalizeConfig as well as
on the file, so it fails where the invariant actually lives.
CHANGE_PERMS spun a worker thread to run existsSync/lstat/readdir/chmod over a
folder tree. That is I/O, not CPU: the other four workers stream or decode and
belong in a thread, this one paid for a worker script, a ?modulePath import, two
table entries (one of them 0, with a comment saying pooling bought nothing) and
the whole message protocol for one call fired once per Linux install.

changePermissions is now async over node:fs/promises, which satisfies the
filesystem port as it stands, so the delegating nodeFileSystem object goes with
it. The existsSync test in front of each lstat is gone too: a stat the
filesystem refuses is the same skip with no window between the two answers.

The worker's 10 minute bound comes back as an AbortSignal.timeout the walk
checks before each entry, which stops the walk rather than orphaning a thread,
and the handler still reports the same two error texts the worker reported.
The symlink refusal and the 100,000 entry cap are untouched.

permissions.ts moves out of src/ipc/workers/ since no worker runs it now.
@Pixnop
Pixnop requested a review from Zaldaryon September 20, 2026 11:41
The walk used to open each entry with existsSync before lstat. existsSync
resolves links, so a link whose target could not be reached answered false and
the entry was skipped. Replacing both with a single lstat changed that: lstat
does not follow links, so the link itself stats fine and the walk refuses it.

The class is every link existsSync could not resolve, a dangling target, a
loop, a target behind a folder with no execute bit. The handler turns that
refusal into "Changing permissions failed", and the renderer's extract task
fails on it by design, so a Linux install into a folder holding one stale link
failed where it used to complete.

The link is now tested with access, which follows links the way existsSync did,
and refused only when something is there for chmod to resolve it to. Nothing is
applied on either branch, so the gap between the two answers leaves nothing for
the tree to change under.

The handler test that pinned the refusal built its link over a file it never
wrote, so it was passing on the dangling arm rather than the one it names. It
now writes the target first, the way the case in permissions.test.ts always did.
The ten minute bound was only ever observed at throwIfAborted, which runs
before each entry. A syscall that never settles never reaches the next check,
so the returned promise stayed pending for good and the handler never rejected.
That is the one case the bound is named for: a hard NFS or FUSE mount that goes
away leaves the thread in uninterruptible sleep, and the walk never comes back
to look at the signal.

The bound the worker carried did not depend on the worker's state. It was a
timer on the main thread, which is why the thread it gave up on was discarded
rather than reused. Racing the signal against the walk restores that: the call
settles when the signal fires, whatever the walk is doing, and the walk is
abandoned in the same way.

The check between entries stays. It is what stops a walk still making progress
from touching the rest of the tree.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant