refactor(cli): move the async leaves and the calibration child runner onto Effect - #554
Merged
Conversation
… onto Effect
The CLI is Effect at the command layer but raw async below it. Three of those
raw spots cost real correctness, so convert them and leave the rest alone.
Leaves, each with only Effect callers:
* credential-store spawns `security`/`secret-tool` through ChildProcessSpawner
instead of Bun.spawn behind a bare catch. That catch made a broken keychain
indistinguishable from a machine that has none; the cause is now logged
before degrading. MapleConfig captures the spawner in `make`, the way it
already captures `fs`, so MapleConfigValues keeps R = never.
* `maple auth login --with-token` reads stdin through Stdio rather than a
hand-rolled Promise over stdin events. Deliberately NOT Terminal.readLine:
that waits for a readline "line" event and never resolves at EOF, so
`printf tok | maple auth login --with-token` would hang forever.
* update.ts drives tar/xattr through ChildProcess and its filesystem work
through FileSystem. mapFsError detected EACCES via a bare `.code`, which a
PlatformError does not expose, so the actionable "re-run the installer"
message would have silently disappeared — it now also reads the
PermissionDenied reason tag and the wrapped cause.
Calibration child runner (archive.ts), converted as one unit so raw code never
wraps Effect:
* The `settled` flag, the setTimeout watchdog and the 500ms setInterval disk
poller become a forked killer fiber racing a sleep against a sleep-first
poll loop. The poll keeps its fail-loud catch INSIDE the poll so a read
error still kills the candidate rather than silently killing the poller.
* The group reap moves into a scope finalizer, so it runs on interruption and
defects too. Previously it only ran from inside a timer callback, and a
Ctrl-C mid-candidate orphaned the Maple grandchild.
* `pgid` could be 0, and POSIX kill(0, sig) signals the CALLER's own process
group — the CLI would have SIGKILLed itself. Guarded.
* Completion still gates on the pipes draining, not on exit: exitCode alone
resolves on "exit", which Node emits before stdio is guaranteed to drain.
exitCode also FAILS on signal death, and every watchdog kill is a signal
death, so that is collapsed to a null code — otherwise one killed candidate
would abort all six signals instead of eliminating one matrix cell.
* The closing reconcile keeps its ArchiveError instead of being orDie'd, and
no longer masks a matrix failure the way the original `finally` did.
runCandidateChild had no unit coverage at all (it was an unexported promise
closure reachable only from the shell probes); it is exported now with four
tests, including a group-kill test that fails if the reap is child-only.
checkpoints.ts pins/locks, durable-files, serve.ts and the archives/migrations
bulk stay raw: their callers are still promise-based, and converting them would
mean either shims or a 15k-line diff.
Testing the refactored credential path end-to-end turned up a pre-existing bug that predates the Effect conversion: `maple auth login` on macOS recorded `credentialStore: "keychain"` and stored an EMPTY password, so the token was lost. saveRemoteCredential skips the file fallback whenever the keychain write reports success, so the next command saw no token at all and the user looked logged out. `security add-generic-password -w` with no argument does not read the secret from stdin as the old comment claimed. It PROMPTS for it — which is still what keeps the secret out of the process list — and then asks for it to be RETYPED. A single piped line fails that confirmation, and `security` stores an empty password and exits 0 anyway. Feeding the secret twice answers the prompt, for both create and update. Neither helper reports a partial write through its exit status, so also read the secret back and require an exact match before claiming the keychain owns it. A mismatch now returns false and the caller falls back to file storage, which works. That also covers a secret `security` echoes back hex-encoded (it does this for non-ASCII data), where the keychain is genuinely not usable as-is. Verified by round-tripping a realistic ASCII token against the real keychain on a throwaway origin. Left untested automatically: a keychain test would either touch a developer's real login keychain or need a fake spawner whose partial stub `test/` cannot typecheck. Also cover extractTar and mapFsError, both of which this branch converted with no tests behind them. The mapFsError test is the useful one: it fails against the old bare `.code` check, confirming that a PlatformError really does hide EACCES and that the install-dir advice would otherwise have gone missing.
…osity
The candidate-child drain test passed on macOS and failed on Linux CI. The
diagnostic is `stderr\n stdout\n <time report>` and truncation keeps only the
first and last 800 characters, so the surviving tail is the time report — and
GNU `time -v` writes ~721 bytes where BSD `time -lp` writes a few lines. With a
200-line payload the assertion on the FINAL stdout line landed in the discarded
middle on Linux. Passing on macOS was luck, not evidence.
Split into two tests that each hold on both platforms:
* drain: a payload small enough that the diagnostic never truncates on any
platform, asserting the first AND last line survive. The last line is the
one that matters — a stdout cut short at exit would still produce the head.
* truncation: the oversized payload, asserting the truncation marker, a length
bound, and that the head survives. All three hold whatever the report says.
Verified by running the suite under Ubuntu as a non-root user (494 pass): the
previous version reproduces the CI failure there, this one does not.
🍁 Maple PR previewNote Preview resources were removed when this pull request closed. Final commit |
Makisuo
added a commit
that referenced
this pull request
Aug 20, 2026
This branch carried its own copy of the CLI Effect refactor, which landed on main separately as #554. Every conflict was that duplicate, and main's copy is the later one in each case, so the conflicted files are resolved to main verbatim: - credential-store.ts — main writes the secret twice, because `security -w` asks the caller to retype it and a single piped line silently stores an empty password while exiting 0, then reads the credential back to prove the keychain actually owns it. This branch predates both. - update.ts — main's `__testables` also exports `extractTar` and `mapFsError`. - archive-candidate-child.test.ts — main splits drain-to-EOF and truncation into separate cases with a payload small enough that `/usr/bin/time`'s output cannot make the assertion platform dependent. What remains of this branch after the merge is one line: project.yml's MapleSwift pin, 0.2.1 -> 0.3.1. Main already resolved maple-swift to 0.3.1 in Package.resolved, so the pin was the half left behind.
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.
The CLI is already Effect at the command layer — Effect CLI v4,
Context.ServiceforMapleConfig/Mode,Schema.TaggedErrorthroughout — but raw async below it. Most of that raw code is straight-line filesystem work that happens to useawaitand is fine as-is. Three spots are not, and this converts those.Scope is deliberately tiered.
src/server/{checkpoints,archives,local-store-migrations}(~15k lines) andserve.tsstay raw; see Out of scope below.Why these three
runCandidateChildwas a hand-rollednew Promisewith asettledflag, asetTimeoutwatchdog and a 500mssetIntervaldisk poller. Ctrl-C mid-calibration left the poller running and the spawned process group orphaned — nothing killed it.credential-store.tsswallowed every error in a barecatch, and its callers usedEffect.promise, which discards failures again. A broken keychain was indistinguishable from "not logged in".const pgid = child.pid ?? 0, thenprocess.kill(-pgid, "SIGKILL").-0is0, and POSIXkill(0, sig)signals the caller's own process group — the CLI would have SIGKILLed itself.What changed
Leaves (each had only Effect callers, so no bridging seams were introduced):
core/credential-store.ts→ChildProcessSpawner.MapleConfigcaptures the spawner inmake, the way it already capturesfs, soMapleConfigValuessignatures stayR = never.commands/auth.tsstdin →Stdio.stdin.core/update.ts→ChildProcessfor tar/xattr,FileSystemfor the fs work.Calibration child-process path (
commands/archive.ts) —runCandidateChild,runBoundCalibrationMatrix,runCalibrationMatrixand the handler, converted as one unit. The watchdog and poller become a forked killer fiber; the group reap becomes a scope finalizer so it also runs on interruption and defects.No new dependencies:
BunServices.layerwas already inMainLayerand providesChildProcessSpawner,FileSystem,Stdio.Reviewer notes
Three near-misses worth a look, since each would have been a silent regression:
Terminal.readLineis the wrong primitive for piped stdin. It waits for a readline"line"event and never resolves on EOF, soprintf tok | maple auth login --with-tokenwould hang forever.Stdio.stdinterminates at EOF andsplitLinesflushes the trailing partial line. Verified against the real runtime for no-trailing-newline, extra-lines, CRLF and empty input.handle.exitCodefails with aPlatformErroron signal death, and every watchdog kill is a signal death. That is collapsed to anullcode, so a killed candidate lands in the samecode !== 0branch as before. Without it, one killed candidate would abort all six signals instead of eliminating a single matrix cell.mapFsErrordetected EACCES via a bare.code, which aPlatformErrordoes not expose — the actionable "re-run the installer" message would have vanished for every converted fs call. It now also checks thePermissionDeniedreason tag and the wrapped cause.Behaviours deliberately preserved verbatim: fail-closed peak-RSS parsing, nonzero exit failing even when a metrics JSON line is present, the 1600-char diagnostic truncation, the fail-loud disk-poll read error (its catch lives inside the poll, so a read error can't silently kill the poller and downgrade fail-loud to fail-late), and gating completion on the pipes draining rather than on
exit.Two intentional deviations:
core/executor.tswas left alone. Itsthrow new Errorsits behindWarehouseSqlClient.insert, aPromise<void>port owned by@maple/query-engine, and the executor already wraps it inEffect.tryPromisewithmapWarehouseError. Converting it would change the shape without changing the behaviour.ArchiveErrorrather than beingorDied into a defect, and a close failure now only decides the outcome when the matrix itself succeeded — so unlike the originalfinally, it no longer masks the matrix error.A bug this turned up
Exercising the refactored credential path against a real keychain surfaced a pre-existing bug (present before this branch — the original
Bun.spawnversion reproduces it identically): on macOS,maple auth loginrecordedcredentialStore: "keychain"and stored an empty password, so the token was lost.saveRemoteCredentialskips the file fallback whenever the keychain write reports success, so the next command saw no token and the user looked logged out.security add-generic-password -wwith no argument does not read the secret from stdin as the old comment claimed. It prompts for it — which is what keeps the secret out of the process list — and then asks for it to be retyped. A single piped line fails that confirmation, andsecuritystores an empty password and exits0anyway. The secret is now fed twice, and the write is verified by reading it back, so a partial write falls back to file storage instead of silently losing the token.This is exactly the class of failure the old bare
catchwas hiding, which is why it is fixed here rather than filed separately.Testing
typecheck,oxlint,oxfmt --checkandbun test(493 pass) are green inapps/cli.New coverage for code this branch converted that previously had none:
runCandidateChild— four tests (signal death, nonzero exit alongside a valid metrics JSON line, the group kill, output written immediately before exit). It was an unexported promise closure reachable only from the shell probes. The group-kill test is mutation-verified: swapping the reap for a child-only kill makes it fail.extractTarandmapFsError. ThemapFsErrortest is mutation-verified too — it fails against the old bare.codecheck, confirming aPlatformErrorreally does hide EACCES.Manual, against the real runtime:
test/cannot typecheck).maple auth login --with-tokenwith a token piped without a trailing newline — the caseTerminal.readLinewould hang on — plus empty stdin, extra lines and CRLF.Native probes, run locally against a binary compiled from this branch, all passing:
native-archive-calibrate-probe.shnative-archive-calibrate-crash-probe.shnative-checkpoint-smoke.shnative-archive-smoke.shOut of scope
checkpoints.tspins/locks/restore,durable-files.ts,serve.ts, and the archives/migrations bulk. Every conversion here obeys one direction — Effect wraps raw, raw never wraps Effect — and those modules are called from six still-promise-based callers, so converting them would mean either compatibility shims or a 15k-line diff. SplittingwithMaintenanceLock/withRestoredCheckpointintoacquireReleaseshapes is the natural next tier; it also unblocksrunCalibrateSample.Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.