Conversation
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.
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.
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.
All four findings of #487, one commit each, in the issue's suggested order.
What changes
1.
logManager.logMessageindexes the logger instead of switching on it (src/utils/logManager.ts, 21 lines removed, 6 added). The five members ofErrorTypesare 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 olddefault: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
gameVersionIdarms 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. Oneundefinedtest now decides whether the key is written; the value isnullor whateverassertStringaccepts.3.
configManagerwrites the normalized config as it stands (src/config/configManager.ts, the wholewriteConfigwrapper removed). It used to run the document throughJSON.parse(JSON.stringify(...))to drop underscore keys, which cost a second full serialise of installations, versions, backups, icons and accounts on every save, sincewriteJsonAtomicstringifies too. The only value that ever reaches the writer is anormalizeConfigresult, 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, whensaveConfigwrote the incoming config straight to disk).4. The chmod walk runs on the main thread (
src/ipc/workers/changePermsWorker.tsdeleted,src/ipc/workers/permissions.tsmoved tosrc/ipc/permissions.ts).CHANGE_PERMSspun a worker thread forexistsSync/lstat/readdir/chmodover 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?modulePathimport, two table entries (one of them 0, with a comment saying pooling bought nothing) and the wholerunTrackedWorkermessage protocol, for one call fired once per Linux install.changePermissionsis now async overnode:fs/promises, so the delegatingnodeFileSystemobject goes with it.The
existsSynctest in front of eachlstatdoes not simply go away, and the first revision of this branch was wrong about that.existsSyncfollows links;lstatdoes 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 onelstatturned a skip into a refusal. The walk now asksaccessabout a link before refusing it, which is the questionexistsSyncwas 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_MSfed toAbortSignal.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
existsSyncturned a skipped link into a failed install.existsSyncis anaccess(F_OK), so it resolves links: a link whose target could not be reached answered false and the entry was skipped beforelstatSyncever ran. A singlelstatstats the link itself, reportsisSymbolicLink(), and the walk refuses it. The class is every linkexistsSynccould not resolve: a dangling target, a symlink loop, a target behind a folder with no execute bit.pathsHandlers.ts:676turns the refusal intoChanging permissions failedandTaskManagerContext.tsx:411fails 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 withaccessand 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 inpermissions.test.tsalways did.The ten minute bound did not cover the case its own comment names.
AbortSignal.timeoutwas only ever observed atsignal?.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 atpathsHandlers.ts:70-76claims to cover. The bound the worker carried never depended on the worker's state either; it was asetTimeouton 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 inchangePermissions, the three outcomes of thegameVersionIdcheck, 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.tsandtests/i18n/i18n-parity.test.tsare unchanged and green.serveTaskskeeps its promise-or-value handling even though the synchronous handler that motivated it is gone. It is anawaiteither way, so there is nothing to remove, andtests/ipc/workerHost.test.tsstill 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 onnormalizeConfigas 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.tsported 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, theCHANGE_PERMSblock 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,acquireWorkerasserted 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.
pendingConfigis assigned in exactly one place, fromnormalizeConfig.logMessage's only untyped entry point is theLOG_MESSAGEchannel, which filters the mode against the same five strings first. ThegameVersionIdcorrelation check ingameHandlers.tsstill gets undefined and null as two different answers.changePermissionshas one caller, theCHANGE_PERMShandler, andchangePermshas one renderer call site, inTaskManagerContext's extract task, which awaits it and fails the task on a rejection.Gate against
origin/dev(5ddf93a):npm run typecheckclean on all three projects,npm run lint:ci0 errors and the same 14 pre-existing warnings,npm run format:checkclean.Closes #487. Part of #492.