Skip to content

EPIC-14B: local text-filter commands + command palette - #56

Draft
Joncallim wants to merge 7 commits into
masterfrom
epic/14-text-filters
Draft

EPIC-14B: local text-filter commands + command palette#56
Joncallim wants to merge 7 commits into
masterfrom
epic/14-text-filters

Conversation

@Joncallim

@Joncallim Joncallim commented Sep 6, 2026

Copy link
Copy Markdown
Owner

What this changes

Implements the remaining scope of issue #15 (EPIC-14): local user text-filter commands and their command-palette/Commands-menu integration ("E14B" — Slices 5-8 of planning/epic-14-implementation.md). #54 already shipped the contribution SPI, TOCContribution, and Preview/Export integration (Slices 1-4); #55 reconciled the architecture doc between the two. This PR is the implementation.

Update (first remediation): an adversarial review of the initial implementation (head aacf824) found 17 contract defects (2 P0 data-corruption paths, 5 P1, 6 P2, 4 P3) — the consolidated review. All 17 were remediated at head 1f46609; see planning/epic-14-implementation.md §21.

Update (second remediation): an independent second pass on 1f46609 found the first remediation's own process/palette fixes were incomplete (7 findings — 1 P0, 3 P2, 1 P3, plus a required change), all remediated at head d40d3ee; see §22.

Update (third remediation): an independent third pass — re-reading the whole process/palette implementation from scratch, not only re-checking §22's seven items — found 10 further findings (3 P0, 4 P2, 3 P3) in the second pass's own new process machinery and in palette edge cases §22 did not cover. All ten are remediated in this branch; see §23 for the finding-by-finding record. The description below reflects that state. §21/§22 stay as their own passes' historical record and are superseded wherever this description disagrees with them — most notably: process-group emptiness is no longer checked via kill(-pgid, 0), the group leader is deliberately never reaped until this session is completely done with it, every posix_spawn setup call is checked, and "process containment" is now honestly scoped to the invocation's initial process group, not "the whole process tree."

How it works

  • TextFilters (SPM target, Foundation only): TextFilterCommand, TextFilterError, TextFilterCommandDiscovery, TextFilterLaunchContext, and TextFilterRunner backed by TextFilterProcessSession/TextFilterProcessGroup/TextFilterTerminalState.
    • Structured launch only — Process.arguments is always []; input travels solely via stdin, never shell-interpolated.
    • Explicit, bounded working directory (the document's containing folder, else home) and environment (a hard-coded PATH covering both Apple-Silicon and Intel Homebrew prefixes; HOME/TMPDIR; MACDOWN_DOCUMENT_PATH/MACDOWN_SELECTION_LENGTH) — nothing inherited from the app's own process environment.
    • One-shot verdict, and forced containment can never launder into success (third-pass finding [EPIC-00] Project foundations: Xcode 26 project, SPM modules, CI #1): TextFilterTerminalState commits .exited only when child-exit, stdout-EOF, and stderr-EOF have all three actually been observed, and once any verdict commits it is permanent. Critically, TextFilterProcessSession.observeExitAndDrainage() now commits a fail-closed .incompleteOutput verdict before it ever contains a still-alive descendant — containing is what closes a remaining writer's fd and produces "real" EOF, and that EOF must never be able to retroactively turn a deliberately-killed partial stream into success. (The second pass had already eliminated a timer fabricating EOF; this pass closes the remaining ordering gap where containment's own side effect could still satisfy the old check.)
    • Process-group identity safety (third-pass finding [EPIC-01] File & format core: FileStore, FileFormat registry, document lifecycle #2): the group leader's exit is observed via waitid(..., WNOWAIT), deliberately without reaping it — a reaped pid becomes immediately eligible for kernel reuse, and this invocation addresses the whole group by that same pid/pgid number for as long as containment might still need to signal it. reapLeader() is a separate, explicit, idempotent step called only once every group-lifetime operation is complete. Because kill(-pgid, 0) cannot tell a deliberately-held zombie leader from a real live member, group-emptiness is now checked by enumerating real membership via sysctl(KERN_PROC_PGRP) and inspecting each member's actual process state.
    • Checked spawn setup (third-pass finding [EPIC-02] Workspace shell: WindowGroup, NavigationSplitView, commands #3): every fallible call in TextFilterProcessGroup.spawn() — file-actions/attributes setup, both strdup allocations, posix_spawn itself — is checked; a failure anywhere throws with which step failed, before launching on a partially-configured process.
    • Atomic FD closure (third-pass finding [EPIC-03] Tab system: TabStore, tab bar UI, session restore #4): POSIX_SPAWN_CLOEXEC_DEFAULT replaces the previous parent-side fcntl(FD_CLOEXEC) loop, closing every non-stdio descriptor in the child atomically as part of the spawn itself — no window remains during which a concurrently-spawning session's own fork() could inherit this session's pipes.
    • Honestly-scoped containment (third-pass finding [EPIC-06] Markdown engine: swift-markdown parse actor, debounce, front matter, source-range index #7): timeout/cancellation/post-exit cleanup SIGTERM → bounded grace → SIGKILL the invocation's initial process group and confirm it empty before ever reporting completion (TextFilterError.terminationUnconfirmed if it can't be confirmed) — a real, non-trivial guarantee that defeats a plain backgrounded job or an outlived pipeline stage. It is not "every descendant however it regroups itself": a descendant that calls setsid() to leave the group is explicitly, and now testably, outside this contract.
    • Stdout is capped at Limits.maxOutputBytes (4 MB default, enforced atomically). Stderr is hard-capped at 64 KiB, lossily decoded so a truncated multibyte boundary doesn't discard the whole diagnostic.
    • No more process-wide SIGPIPE mutation (third-pass finding [EPIC-07] Native Markdown preview: Textual rendering, split view, scroll sync #8): signal(SIGPIPE, SIG_IGN) is gone; Darwin's descriptor-scoped F_SETNOSIGPIPE is applied to just the one pipe descriptor that can legitimately hit EPIPE.
    • TextFilterCommandDiscovery re-scans its directory on every call, considers only executable regular files, and disambiguates colliding display names with the real filename.
  • TextFilterCoordinator (App target) runs a command against a document's active editor: the selection when non-empty, else the whole document. Stale-completion rejection via a captured baseline; editing-assist-fidelity via EditorTextSystem.applyExternalReplacement; per-tab task ownership with window-close cancellation and same-tab supersession; no stale-alert on a cancelled/superseded task, and no runModal() fallback for a gone origin window.
  • Command palette (CommandPaletteModel + CommandPaletteView + CommandPalettePanel): a small floating panel, fully keyboard-operable, combining app commands with discovered text filters.
    • Owned by one strong reference on WindowCoordinator; auto-dismisses when its origin window closes; row availability (including text-filter availability) is re-checked live at invocation time, not frozen at open time.
    • Every command threads the captured origin window through its entire async chain instead of ever resolving NSApp.keyWindow — including, as of this pass, Save's own destination fallback (finding [EPIC-04] EditorCore: NSTextView + TextKit 2 representable, performance baseline #5: WorkspaceModel.requiresDestinationToSave lets WindowController.saveDocumentFromExplicitOrigin() route an untitled/unavailable-backed document to the explicit-origin destination flow instead of save()'s own ambient one) and the no-document-origin case (finding [EPIC-05] Tree-sitter highlighting engine + theme system #6: New Tab/Open…/Open Folder… are now hidden, not merely no-ops, when the palette has no live document window as its origin — each previously fell back to NSApp.keyWindow, the palette panel itself, deeper in its own async chain).
    • Export… is not a palette command at all (removed, not given the same treatment).
  • "Commands" menu: discovered filters, "Show Commands Folder," "Add Example Scripts" (exclusive-create semantics; never overwrites a concurrently-created file).
  • planning/extension-api-design.md (issue [EPIC-14] Extension points: first-party contribution seam + user text-filter commands #15 deliverable 4): design-only; nothing in it ships in 1.0.

Residual limitations (tracked, not silently dropped)

Commands run and observed outcomes (third remediation, current head)

swiftformat --lint MacDown2         → 0/423 files require formatting (5 skipped)
swiftlint lint --strict MacDown2    → 0 violations, 0 serious, 423 files

cd MacDown2/Packages/MacDownKit && swift build && swift test --no-parallel
  → Build complete; 1121 tests in 124 suites passed

xcodegen generate
xcodebuild -scheme MacDown2 -destination 'platform=macOS' build                          → BUILD SUCCEEDED
xcodebuild -scheme macdown2 -destination 'platform=macOS' build                          → BUILD SUCCEEDED
xcodebuild -scheme MacDown2 -configuration Release -destination 'platform=macOS' build   → BUILD SUCCEEDED
xcodebuild -scheme macdown2 -configuration Release -destination 'platform=macOS' build   → BUILD SUCCEEDED
xcodebuild -scheme MacDown2 -destination 'platform=macOS' -enableCodeCoverage NO build-for-testing
  → TEST BUILD SUCCEEDED

xcodebuild -scheme MacDown2 -destination 'platform=macOS' -enableCodeCoverage NO \
  -parallel-testing-enabled NO -only-testing:MacDown2Tests test-without-building
  → Test run with 113 tests in 15 suites passed (full app-target suite; was 111 before this pass)

-enableCodeCoverage NO is required for build-for-testing/test-without-building on this toolchain (Xcode's test action instruments this repo's pure-C SwiftPM package targets with -fprofile-instr-generate regardless of the scheme's own coverage setting, and the profiling runtime never links into them — no coverage report is consumed anywhere in this pipeline). -parallel-testing-enabled NO remains required for this suite's load-sensitive tests.

A deliberate scope decision from this pass, stated plainly: finding #3 (checked spawn setup) suggested a full syscall-mocking seam for fault injection. That was assessed and not built — most of the ~10 individual POSIX calls essentially never fail outside OOM, and two of the realistic failure paths (a nonexistent working directory, a closed source file descriptor) are covered with real, deterministically-triggerable failures instead, without mocking anything. See §23 for the reasoning in full.

Manual matrix — unverified

epic-14-implementation.md §14 names two on-device journeys this PR cannot execute interactively in a non-interactive session: the palette keystroke path (⌘⇧P, type to filter, arrow/Return to invoke) and the text-filter replacement keystroke-to-undo-step path (select text, run a command, ⌘Z to undo). Do not infer either passed from the unit-test evidence above — a human (or an interactive session) should run through:

  • ⌘⇧P opens the palette; typing filters both app commands and text filters; arrow keys move the selection; Return invokes; Escape dismisses. Reopen it several times and confirm no leaked/defunct panel.
  • Export… is absent from the palette; the normal app Export menu item still works unchanged.
  • Commands menu → "Add Example Scripts", then "Uppercase Selection" on a selection and on a whole document (no selection) — output replaces the right range, one ⌘Z undoes it.
  • With Markdown editing assists on, select text and run a filter whose output is a single */`/underscore — confirm it lands verbatim, not expanded into a Markdown span.
  • A command that exits non-zero, hangs past 10s, or produces no output — each preserves the original text except the empty-output case, which is a successful deletion of the selection/document.
  • A command whose script backgrounds a detached grandchild before responding — timeout/cancellation should not leave it running, unless that grandchild calls setsid()/setpgid() to leave the process group, which is explicitly out of scope (third-pass finding [EPIC-06] Markdown engine: swift-markdown parse actor, debounce, front matter, source-range index #7).
  • Start a slow filter, then close its window before it finishes — confirm no crash and no alert.
  • Open two document windows, A and B. From the palette opened on A, with B given focus in between before completing each action: New File, Open…, Open Folder…, Save As…, New Tab, Save, Close Tab, Toggle Sidebar should all act on A, never B.
  • Palette Save on an untitled document, and on one whose backing file has become unavailable, from origin A while B is ambient — the destination panel and the resulting save must both belong to A (third-pass finding [EPIC-04] EditorCore: NSTextView + TextKit 2 representable, performance baseline #5; the already-backed-document case is covered by an automated test, this one isn't).
  • Open the palette on a window, then close that window while the palette is still open — the palette should dismiss itself rather than staying open against a dead origin.
  • Open the palette while no document window is key (e.g. from a non-document window, if reachable) — New Tab/Open…/Open Folder… should not appear as rows at all (third-pass finding [EPIC-05] Tree-sitter highlighting engine + theme system #6).

Risk and rollback

  • All new code is additive: a new TextFilters SPM target (Foundation only) and new App-target files, plus explicit-target methods on WindowCoordinator that the real menu commands now delegate to unchanged.
  • Text-filter commands are the first subprocess launch in this codebase; the safety obligations are enumerated in epic-14-implementation.md §10. Coverage: TextFiltersTests/TextFilterProcessGroupTests/TextFilterRunnerAdversarialTests (process/lifecycle/termination, including a real PID-based process-group containment matrix and process-group-identity-safety ordering tests), App-target TextFilterCoordinatorTests, and App-target PaletteOriginTargetingTests/CommandPaletteStaleOriginTests for the palette-targeting fixes across all three passes.
  • Revert is a plain git revert of this PR's commits.

Links

This PR remains a Draft pending human execution of the manual matrix above, and another independent adversarial pass — nothing in this description or in CI substitutes for either.

🤖 Generated with Claude Code

Joncallim and others added 4 commits September 6, 2026 21:30
New TextFilters SPM target (Foundation only, per epic-14-implementation.md
§5): TextFilterCommand, TextFilterError, TextFilterCommandDiscovery,
TextFilterLaunchContext, and TextFilterRunner backed by
TextFilterProcessSession.

TextFilterProcessSession launches with Process.arguments always []
(input travels only via stdin, never shell-interpolated), an explicit
working directory (the document's containing folder, else home) and a
fixed minimal environment (hard-coded PATH; HOME/TMPDIR;
MACDOWN_DOCUMENT_PATH/MACDOWN_SELECTION_LENGTH; nothing inherited from
the app's own process environment). stdin is written and stdout/stderr
are read concurrently via readability handlers (never sequentially,
which would deadlock a script whose output exceeds one pipe buffer);
SIGPIPE is ignored process-wide so a script that never reads stdin
can't crash the app. A watchdog Task mirrors PDFNavigationDelegate's
existing watchdog-vs-continuation race exactly: whichever of normal
exit, timeout, or caller cancellation resolves first wins, guarded by
a lock so only the first resolution is ever honored. Stdout is capped
at Limits.maxOutputBytes (reading stops and the process is terminated
on overflow); stderr is separately capped at 64 KiB purely for the
error message. TextFilterCommandDiscovery re-scans its directory on
every call (no cache to invalidate) and only ever considers executable
regular files, never recursing into subfolders.

26 tests covering the happy path, every failure mode in §9's table
(launch failure, non-zero exit, timeout, cancellation, oversized
output, non-UTF-8 output), the security/trust boundary in §10 (no
shell interpolation even for stdin containing shell metacharacters,
environment contains only the documented variables, working directory
correctness), and the §15 adversarial corpus (a forked grandchild
process is never waited on, concurrent invocations of the same script
don't corrupt each other's output, running a filter does not block
concurrent main-actor work).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
TextFilterCoordinator (mirrors ExportCoordinator's shape) resolves the
key window's active editor/document, runs the selected command with
the selection as input when non-empty, else the whole document,
and applies the output as exactly one undoable edit — the existing
public applyDocumentReplacement for the whole-document case, and a
small local range replacement using EditorTextSystem's own public
textView/undoManager for the selection case (the same
breakUndoCoalescing -> insertText -> breakUndoCoalescing idiom
EditorCore already uses, without widening its public surface). A
stale selection (an incompatible edit happened while the command ran)
is clamped to the live text rather than rejected. Cancellation shows
no alert (a deliberate withdrawal); every other failure shows one
naming the command.

TextFilterCommands adds the "Commands" menu: discovered filters
(re-scanned on every SwiftUI commands re-evaluation, per
TextFilterCommandDiscovery's own no-cache design), "Show Commands
Folder," and "Add Example Scripts" — deliberately manual, no
auto-install. BundledExampleScripts embeds two example scripts
(uppercase, sort lines) as string literals rather than bundled
resource files, installed executable and never overwriting a file
the user already has.

8 app-level tests cover the pure, extractable logic: range clamping
(valid/past-end/negative/empty-document cases) and script
installation (executable bit, no-overwrite, directory creation).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
CommandPaletteModel is pure row-building/selection logic (query
filtering, wraparound selection movement, dispatch-by-kind
invocation) kept free of any AppKit/SwiftUI presentation so it is
directly testable without presenting UI. AppPaletteCommand is the
small, explicit array of app commands (New File, Open, Save, Export,
Toggle Sidebar, Show Commands Folder); CommandPaletteRow visibly
distinguishes a discovered text filter from a built-in app command
(§10) via its kind, both in the row UI and its accessibility label.

CommandPaletteView is fully keyboard-operable — type to filter, arrow
keys to move the selection, Return to invoke, Escape to dismiss — per
§12, with accessibility identifiers/labels on the search field and
each row. CommandPalettePanel hosts it in a small floating NSPanel
created per invocation; WindowCoordinator.toggleCommandPalette()
(⌘⇧P, wired in TextFilterCommands) shows or closes it by scanning
NSApp.windows rather than tracking a stored panel reference, keeping
WindowCoordinator's own class body under its lint budget.

9 tests cover row filtering/combination/case-insensitivity, wraparound
selection movement (including the empty-list no-op case), dispatch to
the correct handler by row kind, and that refreshRows() re-scans text
filters on every call rather than caching a stale list.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds planning/extension-api-design.md (issue #15 deliverable 4): a
design-only description of a possible post-1.0 JavaScriptCore
third-party extension API — what it could register (a
Contributing-shaped transform), the JSContext sandboxing model (no
filesystem/network/process access), open distribution/discovery
questions, and its relationship to E19/E20/E21. No code; nothing
described ships in 1.0.

Reconciles epic-14-implementation.md (Slices 5-8 now done, §20 records
the concrete implementation decisions where earlier sections were
illustrative) and planning/epics/README.md's E14 row (now done,
pending on-device journey verification) with what actually shipped.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

Copy link
Copy Markdown
Owner Author

Adversarial merge-gate review — 17 validated findings

Reviewed head aacf824db001edd29f4f85b71d98f34e14eb932e. I ran repeated orthogonal passes over the full 28-file diff plus the surrounding editor/window/process architecture and the current issue/release contracts: data-integrity/stale-result handling, TextKit mutation semantics, subprocess I/O, timeout/cancellation/process-tree lifetime, AppKit window/focus ownership, command semantic parity, discovery/identity, environment/trust boundary, resource bounds, automated evidence/CI, and handoff/docs.

I stopped only after the final full re-read produced no additional independent validated finding. Current result: 2 P0, 5 P1, 6 P2, 4 P3. Per planning/RELEASE_HARDENING.md, the P0/P1 set blocks merge.

Summary

# Sev Finding
1 P0 A slow filter can overwrite or corrupt edits made while it is running
2 P0 Process exit races pipe draining; a zero-exit filter can insert truncated stdout, and the output cap can lose its race
3 P1 Markdown editing assists can rewrite valid filter output instead of inserting it verbatim
4 P1 Timeout/cancellation reports success at stopping a process after only SIGTERM; a TERM-ignoring command can remain alive
5 P1 Filter tasks have no document/window owner, so closing a window does not cancel them as the architecture promises
6 P1 The command palette has a strong retain cycle and incorrect NSPanel lifetime assumptions; reopening is broken/leaky
7 P1 Palette app commands run while the palette is the key window, breaking contextual commands such as Save/Save As/Close Tab/New Tab
8 P2 Palette command semantics/enablement have already drifted from the real menu (New File == New Tab, filters remain selectable without an editor)
9 P2 Palette re-discovers filters at invocation, converting the specified launch-failure path into a silent no-op; it also rescans disk on every keystroke
10 P2 The bounded PATH omits the standard Apple-Silicon Homebrew prefix, breaking common #!/usr/bin/env power-user filters
11 P2 Humanized command names can collide, leaving two different executables visually indistinguishable and non-deterministically ordered
12 P2 Required app-adapter/undo/lifecycle evidence is missing and app-target tests are not executed in CI
13 P2 Cancellation behavior deliberately contradicts the still-authoritative issue/release failure-visibility contract
14 P3 The PR manual matrix contradicts the implemented zero-exit/empty-stdout semantics
15 P3 Discovery claims “regular files” but accepts executable symlinks
16 P3 The stated 64 KiB stderr cap is not actually a hard 64 KiB cap
17 P3 Example installation has a check-then-overwrite race and treats chmod failure as successful installation

1. P0 — stale filter results can overwrite/corrupt live edits

Proof. TextFilterCoordinator.run snapshots the selection and input, awaits TextFilterRunner, then applies the result to the retained EditorTextSystem with no document identity/generation/content-revision check. For a selection it deliberately clamps the old coordinate into the new live string; for whole-document mode applyDocumentReplacement replaces whatever text is live when the command finishes.

A deterministic selection example:

command-time text:   "one two"
command-time range:  {4,3} -> "two"
filter output:       "TWO"
user edits meanwhile by inserting "X " at the front
live text:           "X one two"
old {4,3} now means: "e t"
current PR result:   "X onTWOwo"
intended live text:  "X one TWO" (or reject the stale result)

Whole-document mode is worse: if a slow uppercase filter snapshots one\ntwo\n and the user adds three\n before completion, the result computed from the old snapshot replaces the current entire editor and deletes three\n.

This is not an unfamiliar problem in this repo: WindowCoordinator.performJSONFormatting already captures text, document.mutationGeneration, and EditorTextSystem.contentRevision, then rejects a stale completion before mutation. E14B bypasses that existing safety pattern.

Impact. Silent authored-text corruption/data loss -> P0 by the release-hardening definition. Concurrent filters can cause the same failure even without a manual edit: both snapshot an old revision, the first changes it, and the later completion applies stale coordinates/output.

Remediation. Capture a filter baseline containing at least originating tab/document identity, document mutation generation, editor identity/content revision, snapshot text, selection/range, and any path state that affects launch semantics. After await, re-resolve the originating document/editor and require the baseline to still match before mutation. Do not clamp an incompatible stale range into unrelated text. Reuse the existing JSON-formatting baseline pattern. Also freeze a policy for overlapping filters: serialize per document, cancel/supersede older work, or reject stale completions.

Tests to add. Selection edit during slow filter; whole-document edit during slow filter; external reload/Save-As during filter; close/reopen; two concurrent filters finishing out of order. Every stale case must preserve the live text exactly and produce zero undo mutation.

2. P0 — process exit is treated as I/O completion, so successful output can be truncated

Proof from the state machine. TextFilterProcessSession installs asynchronous readabilityHandlers, but Process.terminationHandler immediately calls resolve(.exited(status)). waitForOutcome() then resumes, the runner removes both readability handlers, and finalize() snapshots the buffers. Worse, handleStdout begins with guard outcome == nil, so a stdout callback already queued behind the termination callback explicitly discards its bytes after .exited wins.

Process exit only means the writer has exited; it does not mean every byte already buffered in the pipe has been delivered through asynchronous readability callbacks.

I reproduced the exact topology with a Foundation/POSIX harness: direct child writes 1 MiB of zero bytes and exits 0; termination resolves first and the read handler is removed immediately. 24/30 runs captured fewer than the expected 1,048,576 bytes (representative captures: 1,032,192; 1,007,616; 1,040,384). The PR adds an even stronger deterministic discard condition via guard outcome == nil.

There is a second race in the same root state machine. handleStdout appends a chunk, computes overflowed, unlocks, and only then calls resolve(.oversized). A permitted interleaving is:

  1. stdout callback appends the chunk that takes the buffer over 4 MiB and unlocks;
  2. process exits and termination handler wins resolve(.exited(0));
  3. stdout callback's subsequent resolve(.oversized) loses;
  4. finalize() sees .exited(0) and can accept the over-limit buffer.

Stderr has the same exit/drain ordering problem for diagnostics.

Impact. An exit-0 formatter can silently replace the user's selection/document with a valid UTF-8 prefix of the intended output. That is document corruption, not merely a diagnostic truncation -> P0.

Remediation. Do not model direct-process exit as terminal success. Track independent state for process exit status, stdout EOF, stderr EOF, and forced-failure reason. A normal result becomes eligible only after the direct process has exited and both streams are drained to EOF. Enforce the stdout cap atomically before/while appending; once it is exceeded, failure must dominate a later zero exit. Forced timeout/cancel/overflow should initiate a real termination protocol (finding 4), then close/drain deterministically before returning.

Tests to add. Repeated large exact-output test (e.g. 1 MiB/4 MiB ASCII with exact byte equality); maxOutputBytes and maxOutputBytes + 1; a child that writes its last large chunk immediately before exit; large stderr followed by non-zero exit. Run these repeatedly to make scheduling races visible.

3. P1 — Markdown editing assists can transform filter output

Proof. Selection replacement calls NSTextView.insertText directly. Whole-document replacement uses applyDocumentReplacement, which also calls insertText. Neither raises EditorTextSystem.isPerformingEditingAssist.

EditorView.Coordinator.textView(_:shouldChangeTextIn:replacementString:) explicitly intercepts insertText whenever Markdown editing assists are enabled. Markdown documents receive .markdownDefault assists by default. MarkdownEditingAssistEngine.replacementOutcome treats a one-UTF-16-unit replacement as typed input; with a non-empty range, structural/delimiter characters wrap the selected source.

Concrete path: select foo, run a filter whose legitimate stdout is *. The filter contract says replace the selection with *. Instead the editor delegate interprets * as a Markdown assist and applies *foo*; the original insertText("*") is rejected by the delegate. The coordinator then also sets the caret as though exactly one character had been inserted.

The same class of failure applies to matching characters/delimiters such as *, _, backtick and structural openers handled by the assist engine.

Impact. A valid, successful text filter does not get byte/text fidelity through the editor seam. This is a core transform-contract failure.

Remediation. Put external/filter replacement behind an EditorCore method rather than reaching through public textView. The method should perform one native undoable edit while raising only the editing-assist reentrancy guard (not the model-publication guard), so the literal replacement reaches the document and still dirties/publishes normally. Use that single method for both selection and whole-document filters.

Tests to add. App-target tests with Markdown assists enabled where a filter replaces a selection/whole document with each structural one-character output and the final text is exactly stdout, with one undo step.

4. P1 — timeout/cancellation does not guarantee the process has stopped

Proof. .timedOut, .cancelled, and .oversized call terminateIfRunning(), which performs a single process.terminate() and immediately throws. There is no wait for confirmed termination, grace period, escalation, or reap-before-return.

Apple's own Process.terminate() documentation states that it sends SIGTERM and that a task may ignore it: https://developer.apple.com/documentation/foundation/process/terminate(). A targeted process with trap '' TERM remained alive after terminate() in a Foundation reproduction. The current error text nevertheless says, “The command took too long and was stopped.”

The existing timeout test only asserts that the caller returned before the fixture's five-second sleep; it never records/checks the PID. Therefore it passes even when the command remains alive. The cancellation test has the same blind spot. A TERM-ignoring process that also keeps stdin open can additionally leave the background stdin writer blocked with its input Data retained after the runner has returned.

The current adversarial grandchild fixture explicitly codifies another lifetime escape: a background child is allowed to remain alive after the direct shell exits. That may have been an intentional architecture choice, but it conflicts with treating “bounded execution” as a bound on the text-filter process tree rather than merely a bound on how long MacDown waits.

Remediation. Implement an explicit shutdown protocol: request graceful termination, await a short bounded grace period, escalate to SIGKILL if still alive, and do not report timeout/cancellation completion until death is confirmed. Decide and document descendant semantics; for a transform command I recommend a dedicated process group and terminating the group so filters cannot daemonize accidentally. A low-level posix_spawn/process-group wrapper is more robust than trying to infer descendants after launch.

Tests to add. A TERM-ignoring direct child writes its PID; after timeout/cancellation returns, kill(pid, 0) must report no live process. Add a descendant fixture and assert the chosen process-tree policy explicitly.

5. P1 — filter tasks are unowned, so document/window close does not cancel them

Proof. WindowCoordinator.textFilterCoordinator returns a new stateless value every time. TextFilterCommands and the palette both launch unstructured Task { await ...run(command) } values and retain no task handle. WindowController.windowWillClose cancels/evicts other owned lifetimes, but no filter task exists there to cancel.

Therefore the architecture comment in TextFilterCoordinator (“cancelled, e.g. closed the window mid-run”) is not reachable through the window lifecycle. Closing the origin evicts its editor system, but the running filter has already strongly retained the EditorTextSystem; a later success can mutate a detached text view whose document binding/delegate has been torn down. A later failure can attach an alert to whatever window happens to be key instead of the origin.

Repeated invocations are equally ungoverned; there is no per-document serialization/supersession policy, which combines with finding 1 to create out-of-order stale writes.

Remediation. Give running filters an explicit owner, preferably the originating document/tab/window controller. Store task/session handles keyed by document identity, cancel them on close/tab removal/eviction, and define whether a new filter cancels, queues behind, or independently coexists with an existing filter. Capture origin context directly rather than rediscovering it from the later key window.

Tests to add. Start a long filter, close the originating window, assert task cancellation + process death + zero mutation/alert. Start two filters and assert the chosen ordering/supersession behavior.

6. P1 — command-palette panel has a retain cycle and incorrect lifetime assumption

Proof. toggleCommandPalette() creates:

var panel: CommandPalettePanel?
let created = CommandPalettePanel(coordinator: self) { panel?.close() }
panel = created

The panel retains its hosting/content view; the SwiftUI root view retains onDismiss; that closure retains the captured variable box; the box retains panel. This is a strong cycle.

The comment in CommandPalettePanel says AppKit's default isReleasedWhenClosed will deallocate it. That is factually wrong for NSPanel: Apple's current docs state the default is false for NSPanel, and under ARC they warn against forcing release-on-close: https://developer.apple.com/documentation/appkit/nswindow/isreleasedwhenclosed.

The toggle then scans NSApp.windows for any existing CommandPalettePanel. Apple's docs say that list includes existing onscreen and offscreen windows whether visible or not: https://developer.apple.com/documentation/appkit/nsapplication/windows. A closed panel retained by this cycle remains an existing offscreen object, so subsequent toggles can find it, call close() again, and return instead of creating/showing a usable palette. Even if AppKit list membership were to differ in one OS path, the retain cycle itself is permanent.

Remediation. Make palette ownership explicit. Store an observation-ignored panel reference on the coordinator (or a dedicated palette controller), break ownership on windowWillClose, and use weak captures/delegate/notification callbacks rather than a self-retaining local-variable closure. Keep isReleasedWhenClosed = false under ARC; release by removing strong ownership/content references.

Tests to add. Open -> Escape -> open again; open -> close button -> open again; invoke command -> open again. Repeat 100 times and assert at most one live panel and that a weak reference to each dismissed panel becomes nil.

7. P1 — palette contextual app commands run against the palette itself

Proof. The palette is made key via created.makeKeyAndOrderFront(nil). On Return/click, CommandPaletteView.invokeSelectedAndDismiss() invokes an app command synchronously first, then calls onDismiss().

Several standard actions resolve their target from NSApp.keyWindow:

  • saveKeyDocument() requires controller.window == NSApp.keyWindow -> no document controller matches the palette panel -> silent no-op.
  • saveKeyDocumentAs() -> same silent no-op.
  • closeKeyWindow() searches for a document controller whose window isKeyWindow -> no-op.
  • newDocument(addAsTab: true) captures NSApp.keyWindow and can hand the palette panel to addTabbedWindow as the requested tab host.

Toggle Sidebar happens to use the coordinator's cached keyModel instead, so the list is internally inconsistent. Async actions (including filter execution) additionally depend on the panel close restoring the source key window before their queued task resolves its target, creating an avoidable timing dependency.

Impact. The acceptance criterion “command palette invokes app commands” is not met for core app commands; Save is particularly misleading because the palette disappears as though the action succeeded while the dirty document remains unsaved.

Remediation. Capture the originating document/window context before presenting the palette and make command actions target that context explicitly. Do not use ambient NSApp.keyWindow during palette execution. A shared command descriptor can carry an explicit context/enablement predicate. Dismissing/restoring the source window before invocation is a weaker fallback, but explicit target identity is safer for async work.

Tests to add. Through a real CommandPalettePanel, invoke Save, Save As (with injectable panel provider), Close Tab, New Tab, Toggle Sidebar, and a text filter against a known originating document and assert the intended document/window changed.

8. P2 — palette semantics and enablement have already drifted from the real commands

Proof. AppPaletteCommand.standard contains:

New File -> newDocument(addAsTab: true)
New Tab  -> newDocument(addAsTab: true)

They are identical. The real WorkspaceCommands source of truth defines New File as createInKeyFolder(isDirectory: false) and disables it when there is no key folder; New Tab creates an untitled tab. The hand-maintained “accepted drift-risk” list has therefore drifted in the same PR that introduces it.

The palette has no enabled/disabled state at all. It can offer Save/Save As/Close/New File when their real menu equivalents are disabled, and it displays runnable-looking text filters when no active editor exists; choosing one can simply do nothing.

Remediation. Stop duplicating command semantics. Define a small shared command descriptor/bridge used by both WorkspaceCommands and the palette: stable ID, title, contextual target, action, and enablement. The palette should display disabled state or omit unavailable commands consistently.

Tests to add. Assert palette/menu parity for every shared command under: no window, untitled doc, folder-backed doc, dirty/clean doc, multiple tabs, and no active editor.

9. P2 — palette rediscovery turns a specified launch failure into a silent no-op

Proof. CommandPaletteModel.refreshRows() discovers filters. invokeSelected() does another discoverTextFilters() and looks the row ID up again. If the displayed command is moved/deleted or loses execute permission before Return, that second discovery omits it and the method returns silently without calling the filter handler.

This directly contradicts TextFilterCommand's own contract: the value is intentionally a snapshot and a file moved/deleted between discovery and invocation is supposed to reach TextFilterRunner and become an ordinary .launchFailed error. The architecture failure table says the same thing.

Additionally, query.didSet -> refreshRows() means the Commands directory is synchronously rescanned on the main actor on every search-field keystroke, although the architecture only requires filesystem changes to appear between palette openings.

Remediation. Snapshot discovered TextFilterCommand values once per palette opening and filter that in-memory snapshot while typing. Keep a row-ID -> command mapping and invoke the snapshot command; if its executable vanished, Process.run() will produce the intended visible launch failure. Provide an explicit refresh/reopen boundary rather than per-keystroke I/O.

Tests to add. Discovery returns a command for initial rows and [] on a hypothetical later scan; invocation must still dispatch the original command. In an integration test, delete the file after opening and assert a visible .launchFailed, not a no-op.

10. P2 — bounded PATH omits the normal Apple-Silicon Homebrew toolchain

Proof. Launch context hard-codes:

/usr/bin:/bin:/usr/local/bin

MacDown's own CI is now macOS-26/arm64. Homebrew's documented default prefix on Apple Silicon is /opt/homebrew (FAQ: https://docs.brew.sh/FAQ). Consequently a normal power-user filter such as #!/usr/bin/env node, or a shell filter invoking jq, pandoc, prettier, etc. installed through default Homebrew, fails even though the executable is correctly installed. /usr/local/bin covers Intel Homebrew but not the platform the project is actively building on.

This is not made safer by omission in any meaningful capability sense: user filters are already explicitly trusted unsandboxed executables and can invoke /opt/homebrew/bin/... by absolute path. It only makes the bounded environment surprisingly incompatible.

Remediation. Keep the non-inherited deterministic PATH, but include known platform prefixes, e.g. /opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/bin:/bin (or construct a documented architecture-specific fixed list). Do not source shell profiles.

Tests to add. Unit-test the frozen PATH policy for arm64/Intel assumptions and document that shell profile/custom PATH entries are intentionally not inherited.

11. P2 — distinct executables can be visually indistinguishable

Proof. humanizedName strips the extension and normalizes _/-. Therefore all of these collisions are possible:

foo.sh       -> Foo
foo.py       -> Foo
foo-bar.sh   -> Foo Bar
foo_bar.sh   -> Foo Bar

TextFilterCommand.id remains distinct, but both the Commands menu and palette primarily display only name (palette adds the same generic “Command” label to every filter). The sort comparator compares only the humanized name, so tied commands have no explicit deterministic secondary ordering.

Impact. A user can be presented with two identical actions backed by different executables and has no reliable way to know which one they are invoking.

Remediation. Detect display-name collisions during discovery and disambiguate them with the real filename/extension (or always show the filename as secondary text). Add id as a deterministic tie-breaker to sorting.

Tests to add. Colliding filename corpus; assert distinct visible labels and stable ordering across repeated discovery.

12. P2 — the acceptance evidence does not exercise the adapter behavior that matters

Proof. epic-14-implementation.md maps “uppercase a selection end-to-end as one successful editor mutation” to a runner fixture plus an App-target replacement-range/undo-step test. The actual TextFilterCoordinatorTests suite only tests the pure clampedRange helper; it never instantiates an EditorTextSystem, runs a filter through the coordinator, validates binding/dirty state, or performs undo.

Likewise, CommandPaletteModelTests verify only that a synthetic handler is called; they never execute AppPaletteCommand.standard through an actual key palette/window context, which is why finding 7 passes unnoticed.

Finally, CI executes package swift test but app-target tests are only build-for-testing. The PR records a local serial MacDown2Tests pass, which is useful evidence, but regressions in these new app adapters are not continuously executed in PR CI.

Remediation. Add real app-target integration tests for the editor mutation/undo/fidelity/stale/lifecycle cases and standard palette actions. Execute the non-UI MacDown2Tests suite in CI, e.g. the already-proven serial form with -parallel-testing-enabled NO -only-testing:MacDown2Tests test. Keep truly interactive/XCUITest rows separately marked manual/unverified if the hosted environment cannot run them.

The new tests should specifically kill every P0/P1 path above, not just re-test helpers.

13. P2 — cancellation behavior diverges from the authoritative product contract

Proof. Issue #15's mandatory safety contract says launch failure, non-zero exit, timeout, cancellation, or unusable output preserves original text and surfaces a useful error. planning/RELEASE_HARDENING.md likewise requires filter failure to be visible/understandable. The current implementation explicitly catches TextFilterError.cancelled and shows no alert, and the implementation document calls that a deliberate withdrawal.

That may be a reasonable UX decision for a cancellation caused by the user closing a window, but the source-of-truth product contract was not amended; the issue remains open and authoritative.

Remediation. Make an explicit owner decision and reconcile all three layers. Prefer distinguishing cancellation reason: an explicit user dismissal/window close may be silent if the product contract is amended to say so; unexpected/system cancellation should surface status. Do not leave the issue, architecture, tests, and implementation asserting different policies.

Tests to add. Explicit-user-close cancellation versus programmatic/system cancellation, with the exact agreed visibility behavior asserted.

14. P3 — the manual matrix says empty stdout is an error, while code defines it as deletion

Proof. PR manual instructions say a command that “produces no output” should preserve original text and show an error. TextFilterRunnerTests.emptyStdoutOnZeroExitIsASuccessfulEmptyReplacement explicitly asserts the opposite: zero exit + empty stdout is success. The architecture also treats empty stdout as a successful empty replacement.

Unix-filter semantics make the implementation choice defensible — an empty transform can intentionally delete the selection/document — but the human verification handoff is currently guaranteed to score the same behavior both pass and fail depending on which source the tester reads.

Remediation. Pick one policy and make PR body, architecture, tests, and UI agree. If retaining current semantics, change the manual failure case to invalid UTF-8/oversized output and explicitly test “zero exit + empty stdout deletes selected/whole text as one undoable edit.”

15. P3 — “executable regular file” discovery accepts symlinks

Proof. contentsOfDirectory requests .isRegularFileKey, but isExecutableRegularFile never reads it. It only checks fileExists, !isDirectory, and isExecutableFile. Those calls follow a symlink, so an executable symlink to a regular executable is accepted. The implementation/tests/PR description all claim only executable regular files are discovered.

A Foundation probe of the predicate shape reports an executable symlink as fileExists=true, isDirectory=false, isExecutableFile=true while its URL resource type is a symlink rather than a regular file.

Remediation. Actually check URLResourceValues.isRegularFile and, if the intended policy is “no symlinks”, reject isSymbolicLink == true explicitly. If symlinks are intentionally supported, update the contract/docs/tests rather than claiming regular-only discovery.

Tests to add. Executable symlink to a file; symlink to a directory; broken symlink.

16. P3 — stderr can exceed the stated 64 KiB cap

Proof. The code is:

if stderrBuffer.count < Self.maxStderrBytes {
    stderrBuffer.append(chunk)
}

If the buffer is at 65,000 bytes and the next normal read chunk contains 4,096 bytes, the retained buffer becomes 69,096 bytes. The remaining capacity is never calculated. The PR explicitly states stderr is “capped at 64 KiB.”

Truncating at arbitrary callback boundaries can also leave a partial UTF-8 sequence, after which the strict String(data:encoding:.utf8) conversion discards the entire captured diagnostic and falls back to an empty stderr string.

Remediation. Append at most maxStderrBytes - currentCount bytes, and decode truncated diagnostics with a lossy UTF-8 decoder (String(decoding:as:)) if preserving the valid prefix is preferable to dropping it all.

Tests to add. >64 KiB ASCII stderr must result in <=65,536 retained UTF-8 bytes; add a multibyte boundary case.

17. P3 — example installation can violate its “never overwrite” promise

Proof. BundledExampleScripts.install does a classic check-then-act:

guard !fileManager.fileExists(atPath: url.path) else { continue }
try script.contents.write(to: url, atomically: true, encoding: .utf8)

Apple documents that atomic NSString/String file writes replace an existing destination. Therefore another process/user creating url after fileExists but before the atomic rename can have their file overwritten, despite the method's “must never silently overwrite” invariant. Apple also documents a withoutOverwriting data-write option specifically for no-overwrite behavior (and notes it cannot be combined with .atomic because atomic replacement overwrites the destination).

A second issue in the same installer: setAttributes(... 0o755) is try?; installedCount increments even if chmod failed. That leaves a non-executable file occupying the example's name, so future installs skip it and discovery never shows it.

Remediation. Create each tiny script with exclusive-create semantics (open(..., O_CREAT|O_EXCL|O_WRONLY, 0o755) plus a complete write/fchmod path, or an equivalent no-overwrite API). On any write/permission failure, remove the partial file and report failure; increment installed count only after the file is confirmed executable.

Tests to add. Injectable filesystem/exclusive-create failure; chmod failure; concurrent creator race; confirm an existing user file is never replaced.


Merge gate / recommended remediation order

  1. Fix [EPIC-00] Project foundations: Xcode 26 project, SPM modules, CI #1 and [EPIC-01] File & format core: FileStore, FileFormat registry, document lifecycle #2 first: both can silently corrupt authored text.
  2. Fix the process lifecycle as one coherent redesign covering [EPIC-01] File & format core: FileStore, FileFormat registry, document lifecycle #2, [EPIC-03] Tab system: TabStore, tab bar UI, session restore #4, [EPIC-04] EditorCore: NSTextView + TextKit 2 representable, performance baseline #5 and [EPIC-15] Liquid Glass polish + accessibility + whole-app feature-complete audit #16, rather than patching each race independently.
  3. Add an EditorCore external-replacement seam for [EPIC-02] Workspace shell: WindowGroup, NavigationSplitView, commands #3, then make the filter baseline/lifecycle owner use that seam.
  4. Redesign palette ownership/context around [EPIC-05] Tree-sitter highlighting engine + theme system #6[EPIC-08] Content browser: heading outline of the active document #9; a shared command descriptor should also eliminate [EPIC-07] Native Markdown preview: Textual rendering, split view, scroll sync #8.
  5. Close discovery/environment hardening [EPIC-09] Folder browser: lazy file tree, FS watching, CRUD #10, [EPIC-10] Editing assists: list continuation, auto-pairing, indenting #11, [EPIC-14] Extension points: first-party contribution seam + user text-filter commands #15, [EPIC-16] Localization: String Catalogs + Transifex migration #17.
  6. Add the missing integration/CI evidence [EPIC-11] Multi-format support: JSON tools, HTML preview, LaTeX source and language registry #12 and reconcile the owner contracts/docs [EPIC-12] Export: HTML + PDF, templates, themes and derived-content contract #13[EPIC-13] Settings: SwiftUI Settings scene, @AppStorage model, migration map #14.

After those changes I would re-run the same hostile passes, particularly the process I/O state machine and AppKit palette/window lifecycle, because those remediations will alter architecture rather than merely patch local lines.

Current verdict: do not merge aacf824 as-is. The final saturation pass found no further independent issue beyond the 17 above, but the current head contains two data-corruption paths and five additional P1 blockers.

… 6 P2, 4 P3)

Resolves the full finding set from the PR #56 adversarial review
(issuecomment-5560464988):

P0 (data corruption): stale filter completions could overwrite live edits
(baseline generation/revision guard, mirroring performJSONFormatting);
process exit was treated as I/O completion, truncating output (independent
exit/stdout-EOF/stderr-EOF tracking, atomic oversized-cap decision).

P1: filter output could be reinterpreted by Markdown editing assists (new
EditorTextSystem.applyExternalReplacement seam); timeout/cancellation
didn't confirm the process actually died (SIGTERM-then-SIGKILL escalation
with confirmed death); filter tasks were unowned so closing the window
didn't cancel them (per-tab task registry on WindowController); the
command-palette panel leaked via a retain cycle (explicit
coordinator-owned strong reference, weak back-reference); palette commands
resolved against the palette itself instead of the originating window
(captured origin controller threaded through explicit-target coordinator
methods).

P2: palette command semantics had drifted from the real menu; palette
rediscovery turned a launch failure into a silent no-op; the PATH omitted
Apple-Silicon Homebrew; colliding display names were indistinguishable;
app-target tests were build-only in CI; cancellation's visibility
contradicted the acceptance criterion (reconciled by explicit amendment).

P3: the manual test matrix contradicted implemented semantics; discovery
accepted executable symlinks; the stderr cap wasn't actually hard; example
installation had a check-then-overwrite race.

CI now executes MacDown2Tests (not just build-for-testing), serialized.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@Joncallim

Copy link
Copy Markdown
Owner Author

Remediation complete — all 17 findings resolved

Head 1f46609 remediates every finding from the adversarial review above (2 P0, 5 P1, 6 P2, 4 P3). Finding-by-finding record: planning/epic-14-implementation.md §21.

P0 (both were real data-corruption paths, fixed architecturally, not patched):

  1. Stale filter completions could overwrite live edits → TextFilterCoordinator now captures a baseline (document mutation generation + editor content revision) before launch and rejects a completion unless both still match, mirroring WindowCoordinator.performJSONFormatting's existing precedent.
  2. Process exit ≠ I/O completion → TextFilterProcessSession rewritten around independent exit/stdout-EOF/stderr-EOF tracking; normal completion requires all three. The stdout-cap decision is made atomically in the same critical section as the append that crosses it. Verified with a repeated (8x) 1 MiB exact-byte-equality test plus boundary tests at the cap.

P1:
3. Editing-assist reinterpretation → new EditorTextSystem.applyExternalReplacement seam raises the same reentrancy guard the assist adapter uses; verified with a real Markdown-assists-enabled integration test asserting * lands as *, not *foo*.
4. Unconfirmed termination → SIGTERM → bounded grace → SIGKILL → confirmed dead, with a real TERM-ignoring fixture process. Descendant-process-tree policy (direct child only) is now explicit and documented, reconciled against the drain-completion grace period so it doesn't hang on (sleep 5 &)-style backgrounding.
5. Unowned filter tasks → per-tab task registry on WindowController; window close cancels, a new run on the same tab supersedes the old one. Failures now sheet on the originating window.
6. Palette retain cycle → explicit single strong owner on WindowCoordinator, released via the panel's own delegate callback through a weak back-reference.
7. Palette commands resolving against the palette → origin window captured before presentation, threaded through explicit-target coordinator methods for Save/Save As/Close Tab/New File/New Tab and text-filter execution. (Export… is the one command left on the old path — see the PR description's Residual limitations.)

P2/P3: all six P2s and four P3s are fixed as described in the updated PR description — PATH now covers Apple-Silicon Homebrew, colliding names are disambiguated, symlinks are rejected in discovery, the stderr cap is a real hard cap with lossy decoding, example install uses exclusive-create semantics, palette rows reflect real availability and a real discovery snapshot, and CI now actually executes MacDown2Tests instead of only building it. The cancellation-visibility contradiction (#13) is resolved by an explicit amendment in §9 rather than silently picking a side.

Verification: package suite 1090/1090 (was 1080; +10), full app-target suite 94/94 (was 88; +6 new integration tests directly exercising findings #1/#3/#5/#14), SwiftFormat/SwiftLint --strict clean, and app/CLI/Release builds all green — see the updated PR description for the full command list. CI is currently running against this head.

Manual on-device palette/filter keystroke journeys remain unverified per the same non-interactive-session caveat as before (updated matrix in the PR description, including the finding #3/#14 cases to check by hand).

Ready for another adversarial pass whenever convenient.

…port

Second independent adversarial pass on 1f46609. Fixes:

- #1/#3 (P0/P2): TextFilterTerminalState is a new pure one-shot verdict
  state machine — .exited commits only on real exit + real stdout EOF +
  real stderr EOF, never a timer; once committed, permanent.
- #2 (P1): TextFilterProcessGroup spawns the command as the atomic leader
  of its own process group (posix_spawn + POSIX_SPAWN_SETPGROUP) and
  SIGTERM/SIGKILL-contains the whole group, not just the direct child,
  confirming emptiness before ever reporting completion.
- #4 (P2): palette-invoked Open/Open Folder/New File's post-creation
  open/Save As now thread an explicit origin window through their full
  async chain instead of re-resolving NSApp.keyWindow after an await;
  WorkspaceModel.saveAs(to:) is a new AppKit-agnostic Workspace intent.
- #5 (P2): the palette auto-dismisses when its origin window closes,
  revalidates row availability at invocation time (not just at the last
  rebuild), and Toggle Sidebar now requires a live origin.
- #6 (P2): app + CLI Release builds actually run (previously unrun despite
  DoD claiming they were green) and are now a durable CI gate.
- #7 (P3): a cancelled/superseded filter task no longer surfaces a stale
  alert, and the runModal() fallback for a gone origin window is removed.
- Required Change A: Export… removed entirely from the command palette.

See planning/epic-14-implementation.md §22 for the full finding-by-finding
record and real command/test-count evidence.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@Joncallim

Copy link
Copy Markdown
Owner Author

Second adversarial remediation

Baseline reviewed: 1f4660946fa97c1b25e1b515478e387f12806abb
New head: d40d3ee5b6abdfe03ba52e02712726d737b51e0b

Full finding-by-finding record: planning/epic-14-implementation.md §22.

# Sev Finding Disposition
A Export… still in the command palette Removed from AppPaletteCommand.standard entirely; ExportCoordinator/app Export menu untouched
1 P0 Drain-grace timer could fabricate EOF, return truncated stdout as success Redesigned — new TextFilterTerminalState: .exited commits only on real exit + real stdout EOF + real stderr EOF, never a timer
2 P1 Timeout/cancellation bounded only the direct child, not its process tree Redesigned — new TextFilterProcessGroup: atomic process-group-leader spawn (posix_spawn + POSIX_SPAWN_SETPGROUP), whole-group SIGTERM→grace→SIGKILL, confirmed via kill(-pid,0) (fail-closed on non-ESRCH)
3 P2 Late watchdog could overwrite an already-committed verdict Fixed — one-shot commit in the same TextFilterTerminalState redesign
4 P2 Palette origin leaked to ambient NSApp.keyWindow for Open…/Open Folder…/New File's post-creation open/Save As Fixed — explicit origin threaded through the full async chain; WorkspaceModel.saveAs(to:) is a new AppKit-agnostic Workspace intent
5 P2 Palette could retain/act on a stale closed origin Fixed — auto-dismiss on origin close (WindowCoordinator.removeController), live re-validation of row availability at invocation, live-origin guard on Toggle Sidebar
6 P2 CLI Release build never actually run despite DoD claiming it was green Fixed — both app and CLI Release builds run this pass (real output below) and are now a durable CI gate
7 P3 Cancelled/superseded task could surface a stale alert; runModal() fallback on a gone origin Fixed!Task.isCancelled check + no runModal() fallback, via a new injectable alert-presenter seam

Preserved: every first-pass fix (§21) remains in place and passing; no regressions found in this pass's own verification.

Tests added: TextFilterTerminalStateTests (13, new), TextFilterProcessLifecycleTests (net +4: one stale contract test replaced with 5 real-PID containment tests), PaletteOriginTargetingTests (8, new), CommandPaletteStaleOriginTests (5, new), TextFilterCoordinatorTests (+3), CommandPaletteModelTests (+1).

Verification run on d40d3ee:

swiftformat --lint MacDown2         → 0/420 files require formatting
swiftlint lint --strict MacDown2    → 0 violations, 0 serious, 420 files

swift build && swift test --no-parallel (MacDownKit)
  → 1107 tests in 121 suites passed

xcodegen generate
xcodebuild -scheme MacDown2 -destination 'platform=macOS' build                          → BUILD SUCCEEDED
xcodebuild -scheme macdown2 -destination 'platform=macOS' build                          → BUILD SUCCEEDED
xcodebuild -scheme MacDown2 -configuration Release -destination 'platform=macOS' build   → BUILD SUCCEEDED
xcodebuild -scheme macdown2 -configuration Release -destination 'platform=macOS' build   → BUILD SUCCEEDED
xcodebuild -scheme MacDown2 -destination 'platform=macOS' -enableCodeCoverage NO build-for-testing
  → TEST BUILD SUCCEEDED

xcodebuild -only-testing:MacDown2Tests -parallel-testing-enabled NO test-without-building
  → 111 tests in 15 suites passed (was 94 before this pass)

Real counts from the runs actually executed against this head — not copied forward from the first pass's 1090/94.

Residual, honestly unverified: the manual UI matrix in the PR description — including the two new items this pass adds ("Export…" absent from the palette while the normal menu item still works, and a two-window A/B origin-targeting check across all eight palette commands) — was not driven interactively. Not inferred passed.

This PR remains a Draft, pending a human completing that manual matrix and another independent adversarial review pass.

Copy link
Copy Markdown
Owner Author

Third adversarial merge-gate review — 10 validated findings

Reviewed head d40d3ee5b6abdfe03ba52e02712726d737b51e0b. The head remained unchanged through the review and CI MacDownApp#132 is green, including package tests, app-target tests, lint, Debug builds, and Release app + CLI builds.

I did not treat the second-remediation checklist as the review boundary. I re-read the new POSIX process machinery, terminal-state model, pipe/FD lifecycle, Darwin process-group semantics, filter/editor integration, palette origin propagation, no-origin behavior, tests, CI, §22, the PR description, issue #15, and the release-hardening contract from scratch. I then repeated focused passes on process success/failure ordering, forced containment, PID/PGID lifetime, concurrent spawn descriptor inheritance, cancellation, palette Save fallback, and evidence integrity until the final full re-read stopped producing independent findings.

Result: 10 independent findings — 3 P0, 4 P2, 3 P3. The three P0s block merge. The PR should remain Draft.

# Sev Finding
1 P0 Forced post-exit containment can create real EOF and turn a deliberately killed partial stdout stream into a successful document replacement
2 P0 TextFilterProcessGroup reaps the group leader before later PGID probes/signals, so a recycled PGID can make MacDown signal an unrelated process group
3 P0 The hand-written posix_spawn setup ignores every setup/error return code; partial stdio/cwd/containment configuration can fail open
4 P2 Concurrent sessions still have an FD-inheritance race because CLOEXEC is applied after Pipe creation instead of using Darwin's atomic POSIX_SPAWN_CLOEXEC_DEFAULT policy
5 P2 Palette Save is still ambient for untitled/unavailable-backed documents because it falls through to ordinary WorkspaceModel.saveAs()
6 P2 A command palette opened with no document origin still exposes New Tab/Open/Open Folder, reintroducing ambient/palette-window targeting
7 P2 “Whole process tree” is stronger than the implementation: a descendant can leave the initial process group/session and escape killpg
8 P3 Every filter permanently changes the app-wide SIGPIPE disposition even though Darwin provides descriptor-scoped F_SETNOSIGPIPE
9 P3 One key background-grandchild regression test still records $$ in a subshell, so it checks the parent shell PID rather than the descendant it claims to verify
10 P3 Issue #15 still requires cancellation to surface a useful error, while the shipping policy deliberately suppresses cancellation alerts

1. P0 — forced containment can turn partial stdout into “successful complete output”

The second remediation correctly removed fabricated EOF flags: .exited(status) now requires actual child exit + actual stdout EOF + actual stderr EOF. However, the cause of those EOFs is not represented in TextFilterTerminalState.

TextFilterProcessSession.observeExitAndDrainage() currently does this:

  1. wait for the direct child to exit;
  2. record the child exit status;
  3. if stdout/stderr have not naturally reached EOF, wait ~500 ms;
  4. call containGroup() and terminate the remaining process group;
  5. the killed descendants close their inherited stdout/stderr descriptors;
  6. the normal readability handlers observe real EOF;
  7. TextFilterTerminalState now has all three facts and can commit .exited(0);
  8. finalize(.exited(0)) returns the bytes captured before MacDown killed the output-producing descendant.

That means “real EOF” is not sufficient proof of a complete successful transform if MacDown itself caused the EOF by killing a producer that had not finished.

A deterministic reproducer is conceptually:

#!/bin/sh
(
    printf PREFIX
    sleep 2
    printf SUFFIX
) &
exit 0

The direct shell exits 0. The background writer emits PREFIX and keeps the pipe open. After the 500 ms post-exit grace, MacDown terminates the group before SUFFIX is emitted. The termination creates real EOF. The current terminal model can therefore return PREFIX as successful stdout, and TextFilterCoordinator may replace the user's selection/document with that prefix.

This is authored-text corruption/data loss -> P0.

Required remediation

Once MacDown decides it must forcibly contain a still-output-owning group because natural drainage did not complete, success must become impossible before any signal is sent.

Either:

  • commit a fail-closed verdict such as .incompleteOutput before beginning post-exit containment, then contain only for cleanup; or
  • simpler/cleaner: after direct child exit, continue waiting for natural EOF under the existing overall timeout. If EOF never arrives, timeout/incomplete-output wins, then contain and preserve the original text.

Do not let signal-induced EOF satisfy the successful-completion predicate.

The state model should encode that forced containment disqualifies .exited, rather than only tracking three raw facts.

Regression evidence

Add a real-process test where a background descendant prints a prefix, waits longer than the post-exit drain threshold, then would print a suffix. The only acceptable result is a failure preserving the source — never successful PREFIX.

Also add a pure state test proving forcedContainmentStarted -> later EOFs -> cannot become .exited(0).


2. P0 — reaping the process-group leader before containment makes later killpg identity-unsafe

TextFilterProcessGroup.installExitSource() receives the direct child's exit event and immediately calls reap(pid:), which performs waitpid(pid, ..., 0). Therefore waitForExit() returns only after the group leader has been reaped.

Later code continues treating that numeric former PID as a durable process-group identity:

  • groupStillExists() -> kill(-pid, 0)
  • terminateGroup() -> killpg(pid, signal)
  • waitForGroupExit() repeatedly probes the same number.

On Darwin, a process group is identified by the process ID of its group leader. Once the old group is actually gone and the leader has been reaped, that PID/PGID number is no longer an identity token; it can be reused by a later process/group. killpg() targets whichever process group currently has that numeric PGID.

This creates a dangerous race in the current polling/escalation shape. Example:

  1. old filter leader is reaped;
  2. original group becomes empty;
  3. before the next kill(-pid, 0) poll, Darwin reuses that number for an unrelated process that becomes a group leader;
  4. MacDown interprets the new group as the filter still being alive;
  5. after the grace period MacDown can send SIGKILL to the unrelated group.

This is a process-integrity/security boundary failure -> P0, even though the reuse window may be rare.

Apple's killpg(2) contract is explicitly numeric-PGID based, and Darwin's process model defines the process group ID as the group leader's PID. The implementation must therefore preserve identity while it still intends to signal that group.

Required remediation

Do not reap the leader before all group-lifetime work is complete.

A safe Darwin shape is:

  1. use the process exit event only to learn that the direct child has exited;
  2. obtain its exit status without reaping (waitid(..., WNOWAIT) or equivalent);
  3. keep the zombie leader present while descendants are being observed/contained so the PGID remains pinned to this invocation;
  4. enumerate/wait for non-leader members of that PGID (e.g. Darwin process-table/sysctl support) rather than using killpg(pgid, 0) as an “empty” check while the zombie leader intentionally remains in the group;
  5. TERM/KILL descendants while the PGID identity is pinned;
  6. only after containment/drainage is complete call waitpid to reap the leader.

If another identity-safe Darwin design is chosen, prove the same property: no signal can ever be sent using an identifier that has become eligible for reuse.

Regression evidence

Factor the reaper/group-lifetime policy enough to test ordering deterministically: exit observed -> leader not reaped -> containment/drain complete -> final reap.

Also retain a real integration test where the direct leader exits first and a background descendant remains.


3. P0 — posix_spawn setup ignores all failure returns and can launch with a partial safety configuration

The new low-level spawn wrapper calls a large number of fallible APIs and ignores their return values, including the setup for the very safety properties this rewrite exists to guarantee:

  • posix_spawn_file_actions_init
  • posix_spawn_file_actions_addchdir
  • every posix_spawn_file_actions_adddup2
  • every posix_spawn_file_actions_addclose
  • posix_spawnattr_init
  • posix_spawnattr_setflags
  • posix_spawnattr_setpgroup
  • posix_spawnattr_setsigdefault
  • the fcntl(..., F_SETFD, FD_CLOEXEC) calls
  • later waitpid/signal outcomes are also incompletely checked.

Apple's own man pages specify non-zero error returns for these APIs (ENOMEM, EBADF, EINVAL, etc.). A failure to add one file action does not magically prove the later spawn will fail; the current code simply continues with whatever partial action/attribute object exists.

Concrete dangerous class: if the stdout adddup2 operation fails to be added but later setup/spawn succeeds, the command can run with inherited stdout rather than MacDown's capture pipe. The capture pipe can then EOF empty while the command exits 0. E14B explicitly defines zero-exit/empty-stdout as a successful deletion. A low-memory/setup failure can therefore be converted from “launch failed; preserve source” into “successful empty transform; delete source.”

Likewise, ignored addchdir/attribute/pgroup failures can silently drop the explicit working-directory or containment guarantees.

This is fail-open behavior at a data-integrity/process-safety boundary -> P0.

Required remediation

Check every fallible setup/allocation/system-call result. Any setup failure before child creation must fail the launch before user code runs.

Use a helper that turns the numeric POSIX error into a useful .launchFailed(...) diagnostic. Track which file-action/attribute objects actually initialized before destroying them. Check strdup allocations too. Check the eventual waitpid result instead of assuming status 0 on failure.

No safety-related setup call may be best-effort.

Regression evidence

Introduce a small syscall/spawn-operations seam so tests can deterministically inject failures for:

  • file-actions init;
  • stdout adddup2;
  • addchdir;
  • attr init/setflags/setpgroup;
  • CLOEXEC setup;
  • wait/reap.

For every pre-spawn failure, assert no child is launched and the runner returns a visible failure preserving the editor source.


4. P2 — concurrent sessions still have a cross-spawn FD inheritance race

Claude found one real concurrency bug during implementation: one filter child could inherit another concurrent session's pipe descriptors. The remediation sets FD_CLOEXEC on the six pipe descriptors — but it does so inside TextFilterProcessGroup.spawn(), after Foundation Pipe has already created them.

That leaves a multithreaded creation-to-CLOEXEC window:

  1. session B creates its pipes;
  2. before B reaches its fcntl(F_SETFD, FD_CLOEXEC) calls, session A reaches posix_spawn;
  3. child A inherits B's still-unmarked descriptors;
  4. B later waits for stdout/stderr EOF, but child A may be holding an unintended write end open.

The resulting false non-EOF can cause delays, timeout/incomplete-output failures, and cross-session interference.

Darwin already provides the correct atomic spawn policy: POSIX_SPAWN_CLOEXEC_DEFAULT. With that flag, descriptors not explicitly established by file actions are closed in the child. It also prevents unrelated app descriptors from leaking into user filters.

Required remediation

Use POSIX_SPAWN_CLOEXEC_DEFAULT together with explicit stdin/stdout/stderr dup2 actions. Do not rely on a series of parent-side fcntl calls as the global inheritance boundary.

All flag/action setup errors must of course be checked per finding 3.

Regression evidence

Add a barrier/fault-injection test that forces session B's pipe creation to occur before session A's spawn but delays B's own spawn setup. Prove A cannot inherit B's descriptors and both sessions reach true EOF independently.


5. P2 — palette Save still loses its explicit origin when Save needs a destination

The second remediation fixed explicit Save As…, but not Save's own Save-As fallback.

Current path:

AppPaletteCommand.save
-> WindowCoordinator.saveDocument(in: originController)
-> WindowController.saveDocument()
-> WorkspaceModel.save()

WorkspaceModel.save() explicitly does:

if document.fileURL == nil || isBackingUnavailable(document) {
    await saveAs()
    return
}

That saveAs() is the ordinary panel-presenting form using the model's shared/ambient FilePanelProviding path. It does not use the new origin-bound saveDocumentAsFromExplicitOrigin() seam.

This is not theoretical row availability: TabStore.canSave returns true for a non-empty untitled document, so palette Save is visible exactly when this ambient fallback is exercised.

Therefore the PR's statement that every palette command stays origin-bound through every async layer is still false.

Required remediation

Give the palette an explicit-origin Save intent, not merely an explicit-origin Save-As command.

The explicit Save path should:

  • save normally when the current document has a valid backing destination;
  • if Save would require choosing a destination, present the panel against the captured origin and call model.saveAs(to:) directly.

Do not make the package Workspace layer depend on AppKit.

Regression evidence

Two-window tests, origin A / ambient B:

  • non-empty untitled document + palette Save -> destination flow belongs to A;
  • unavailable/missing-backed document + palette Save -> destination flow belongs to A;
  • already-backed document + palette Save remains A-targeted.

The current origin-targeting suite only demonstrates the already-backed Save case.


6. P2 — palette has no defined safe behavior when it opens without a document origin

TextFilterCommands exposes “Command Palette…” app-wide; it is not disabled when no document window is key.

toggleCommandPalette() can therefore capture a Settings/placeholder/non-document key window and produce originController == nil.

Several standard palette commands still have default isAvailable == true in that state, notably:

  • New Tab
  • Open…
  • Open Folder…

For New Tab, the action executes while the palette itself is key:

newDocument(addAsTab: true, relativeTo: controller?.window) // nil

and newDocument falls back to NSApp.keyWindow, which is the palette NSPanel. That is the exact ambient-key failure class the explicit-origin redesign was intended to eliminate, except now it occurs through a nil origin.

Open… similarly uses the ambient provider when controller is nil and later falls back to whatever is key as its tab host. Open Folder with nil origin can silently no-op if no document controller is key.

Required remediation

Define a deliberate no-origin palette policy.

The smallest safe approach is to hide/disable document-context commands when there is no live document origin. If “Open…” should remain globally useful, give it a separate explicit global semantic that opens a standalone document window — never a palette/settings panel as a tab host.

Regression evidence

Open the palette with originController == nil and assert the exact standard command rows/semantics. No action may fall back to the palette panel as a document/tab host.


7. P2 — one process group is not the same thing as the whole descendant process tree

The PR/§22 now repeatedly says timeout/cancellation containment covers the whole process tree and leaves no filter-owned processes alive. The implementation guarantees only the initial process group via killpg(initialPGID, ...).

A descendant can deliberately call setpgid() or setsid() and leave that group. Darwin supports both operations. Once it has escaped, killpg(initialPGID, ...) no longer reaches it.

This does not mean E14B needs to solve hostile daemon supervision at arbitrary complexity: these are user-installed trusted local scripts. But the current implementation/contract claims are stronger than the mechanism actually provides, and issue #15 says execution is bounded.

Required remediation

Make an explicit owner decision:

  1. Recommended for E14: define the safety guarantee as containment of the invocation's initial process group. Explicitly document that a trusted script which deliberately re-groups/creates a new session is outside E14's containment contract; remove “whole process tree / every filter-owned PID” claims.
  2. Or, if the product truly requires descendant-tree containment even after setsid, implement identity-aware Darwin descendant tracking/sweeping. That is materially more complex and still needs careful PID-reuse handling.

Do not imply that (sleep 30 &) is a detached-session test; it is merely a background process and normally remains in the initial group in this non-interactive shell setup.

Regression evidence

Add a fixture that actually calls setsid()/changes process group and record the chosen expected behavior. The test should make the documented boundary executable rather than rhetorical.


8. P3 — text filters permanently modify the app-wide SIGPIPE policy

TextFilterProcessSession.run() calls:

signal(SIGPIPE, SIG_IGN)

This changes the process-wide signal disposition and never restores it. After the first text filter runs, unrelated libraries/components in MacDown inherit different SIGPIPE behavior for the rest of the app lifetime.

Darwin has a descriptor-scoped mechanism specifically for this: fcntl(fd, F_SETNOSIGPIPE, 1) disables SIGPIPE generation for the selected pipe/socket descriptor while still returning the write error.

Required remediation

Remove the process-global signal mutation. Apply F_SETNOSIGPIPE to the parent stdin pipe write descriptor that can legitimately hit EPIPE when a command exits without reading stdin. Check its return code under finding 3's fail-closed policy.

Regression evidence

Capture the app's SIGPIPE disposition before/after a filter and assert it is unchanged. Retain the large-input/script-does-not-read-stdin test to prove the parent still survives EPIPE.


9. P3 — the direct-parent-exits/background-grandchild test is still a false positive

TextFilterRunnerTests.containsABackgroundedGrandchildRatherThanLettingItSurvive() still writes the PID using roughly:

(echo $$ > grandchild-pid; sleep 30) &

POSIX shell semantics are explicit: $$ is the PID of the invoked shell, and a subshell preserves that value. It does not necessarily become the real process ID executing the subshell commands. $! is the background command PID.

So this test reads back the direct shell's PID and then proves the direct shell is dead — not the background descendant it says it verifies.

This is especially important because this exact “leader exits first, descendant remains” topology is the one that exercises findings 1 and 2.

Required remediation

Record a real descendant PID, e.g. $!, or launch a child shell that reports its own real PID after exec. Then assert that PID's lifecycle.

Audit §22's statement that process tests no longer use bare $$; that statement is currently false for this test.


10. P3 — issue #15 still contradicts the chosen cancellation policy

The implementation and §9 amendment deliberately treat cancellation/window-close/supersession as silent withdrawal. TextFilterCoordinator catches .cancelled and presents no error; generic errors are also suppressed when the task has since been cancelled.

But issue #15 — which this PR intends to close — still says in its mandatory safety contract and acceptance criteria that cancellation preserves original text and surfaces/shows a useful error.

Updating epic-14-implementation.md did not update that authoritative product issue.

Required remediation

Given the current UX choice, amend issue #15's cancellation wording/acceptance criterion to distinguish user/system withdrawal from real filter failure. Alternatively revert to visible cancellation, but that would contradict the owner-approved direction from the prior review.

Do not close #15 while its acceptance checklist still says the opposite of the shipped behavior.


Remediation order

  1. Fix [EPIC-00] Project foundations: Xcode 26 project, SPM modules, CI #1 first: forced containment must never be able to produce a successful transform.
  2. Redesign Darwin group lifetime for [EPIC-01] File & format core: FileStore, FileFormat registry, document lifecycle #2 before making further killpg changes.
  3. Harden the spawn boundary in [EPIC-02] Workspace shell: WindowGroup, NavigationSplitView, commands #3/[EPIC-03] Tab system: TabStore, tab bar UI, session restore #4/[EPIC-07] Native Markdown preview: Textual rendering, split view, scroll sync #8 together: checked POSIX setup + POSIX_SPAWN_CLOEXEC_DEFAULT + descriptor-scoped no-SIGPIPE.
  4. Finish palette semantics in [EPIC-04] EditorCore: NSTextView + TextKit 2 representable, performance baseline #5/[EPIC-05] Tree-sitter highlighting engine + theme system #6.
  5. Freeze/document the honest containment boundary in [EPIC-06] Markdown engine: swift-markdown parse actor, debounce, front matter, source-range index #7.
  6. Repair evidence/source-of-truth in [EPIC-08] Content browser: heading outline of the active document #9/[EPIC-09] Folder browser: lazy file tree, FS watching, CRUD #10.
  7. Re-run the full process adversarial matrix, package/app suites, Debug/Release app+CLI, and CI.
  8. Then run another independent hostile pass from scratch; do not only re-check these 10.

Merge verdict

Do not merge d40d3ee. CI being green is real and valuable, but the second remediation introduced/missed three P0 paths in the new low-level subprocess boundary. Keep the PR Draft. Manual UI verification remains pending separately and should not begin until the P0 process semantics are corrected.

…dge cases

Third independent adversarial pass on d40d3ee. Fixes:

- #1 (P0): forced containment can no longer launder into success —
  observeExitAndDrainage() commits .incompleteOutput before it ever
  contains a still-alive descendant, not after.
- #2 (P0): TextFilterProcessGroup no longer reaps the group leader on
  exit — waitid(WNOWAIT) observes the exit fact while leaving the zombie
  held, pinning the pid/pgid identity until reapLeader() explicitly
  releases it once all group-lifetime work is done. groupHasLiveMembers()
  replaces kill(-pgid,0) (unusable once a zombie leader is held) with
  real sysctl(KERN_PROC_PGRP) membership enumeration.
- #3 (P0): every posix_spawn setup call is checked; a failure throws
  before spawning on a partially-configured process.
- #4 (P2): POSIX_SPAWN_CLOEXEC_DEFAULT replaces the parent-side
  fcntl(FD_CLOEXEC) loop, closing the concurrent-spawn FD-inheritance
  window atomically.
- #5 (P2): palette Save no longer falls through to WorkspaceModel's
  ambient saveAs() for an untitled/unavailable-backed document —
  requiresDestinationToSave + saveDocumentFromExplicitOrigin() route it
  through the explicit-origin destination flow instead.
- #6 (P2): New Tab/Open/Open Folder are hidden, not just no-ops, when the
  palette has no live document origin.
- #7 (P2): containment is honestly documented and tested as scoped to the
  initial process group, not "the whole process tree" — a descendant that
  calls setsid() is explicitly outside the contract.
- #8 (P3): removed the process-wide signal(SIGPIPE, SIG_IGN) mutation in
  favor of descriptor-scoped F_SETNOSIGPIPE on just the parent's own
  stdin-write pipe.
- #9 (P3): fixed a test that recorded $$ inside a subshell and so
  verified the wrong process's pid.
- #10 (P3): amended issue #15's cancellation wording to match the
  shipped silent-withdrawal behavior.

See planning/epic-14-implementation.md §23 for the full finding-by-finding
record and real command/test-count evidence.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@Joncallim

Copy link
Copy Markdown
Owner Author

Third adversarial remediation

Baseline reviewed: d40d3ee5b6abdfe03ba52e02712726d737b51e0b
New head: bff930fb19f20cf4b1255153caf8900227e3758f

Full finding-by-finding record: planning/epic-14-implementation.md §23.

# Sev Finding Disposition
1 P0 Forced post-exit containment could create real EOF and turn a killed partial stdout stream into a successful document replacement Fixed by reordering. observeExitAndDrainage() now commits .incompleteOutput before it ever contains a still-alive descendant, not after — the existing one-shot verdict gate then makes the containment-caused EOF unable to retroactively change the result.
2 P0 TextFilterProcessGroup reaped the group leader before later PGID probes/signals, so a recycled PGID could make MacDown signal an unrelated process group Redesigned. Exit is now observed via waitid(..., WNOWAIT) without reaping; the leader stays a held zombie (pinning the pid/pgid identity) until a new, explicit, idempotent reapLeader() releases it once all group-lifetime work is done. groupHasLiveMembers() replaces kill(-pgid,0) (unusable once a zombie leader is held — it reports "exists" forever) with real sysctl(KERN_PROC_PGRP) membership enumeration.
3 P0 The hand-written posix_spawn setup ignored every setup/error return code; partial stdio/cwd/containment configuration could fail open Fixed. Every fallible call in spawn() is now checked; a failure anywhere throws before launching on a partially-configured process.
4 P2 Concurrent sessions still had an FD-inheritance race because CLOEXEC was applied after Pipe creation Fixed. POSIX_SPAWN_CLOEXEC_DEFAULT replaces the parent-side fcntl loop, closing non-stdio descriptors atomically as part of the spawn — no window remains for a concurrently-spawning session's fork() to inherit them.
5 P2 Palette Save still lost its explicit origin for an untitled/unavailable-backed document, falling through to WorkspaceModel's ambient saveAs() Fixed. New WorkspaceModel.requiresDestinationToSave + WindowController.saveDocumentFromExplicitOrigin() route that case to the existing explicit-origin destination flow instead.
6 P2 A palette with no document origin still exposed New Tab/Open…/Open Folder…, each falling back to NSApp.keyWindow (the palette itself) Fixed — hidden via isAvailable: controller != nil, the recommended "smallest safe" option.
7 P2 "Whole process tree" overclaimed the guarantee: a descendant calling setsid()/setpgid() can leave the group and escape killpg Documented as the explicit, accepted boundary, not implemented further — containment is honestly scoped to the invocation's initial process group. Made executable via a real setsid() fixture, not only a doc comment.
8 P3 Every filter permanently changed the app-wide SIGPIPE disposition, never restored Fixed — removed; Darwin's descriptor-scoped F_SETNOSIGPIPE applied to just the one pipe descriptor that needs it.
9 P3 A regression test recorded $$ inside a subshell, verifying the wrong process's pid Fixed, and §22's claim that this no longer happens anywhere is acknowledged as having been inaccurate for this one test — corrected in §23 rather than silently edited into §22's own record.
10 P3 Issue #15's acceptance criteria still said cancellation must show an error, contradicting the shipped silent-withdrawal behavior Fixed by amending issue #15, not the behavior — its safety contract and checklist now list cancellation as its own bullet: text preserved, no alert, by design.

Preserved: every fix from §21/§22 remains in place and passing.

Tests added: TextFilterProcessGroupTests (6, new file), TextFilterRunnerAdversarialTests (new file, split out of TextFilterRunnerTests for file-length budget; 3 of its 6 tests are new this pass), 1 new test in TextFilterTerminalStateTests, RequiresDestinationToSaveTests (4, new file), 2 new tests in CommandPaletteStaleOriginTests.

A deliberate scope decision, stated plainly: finding #3's suggested full syscall-mocking seam for fault injection was assessed and not built — most of the ~10 POSIX calls essentially never fail outside OOM; two realistic failure paths (nonexistent working directory, closed source fd) are covered with real, deterministically-triggerable failures instead. See §23 for the reasoning.

Verification run on bff930f:

swiftformat --lint MacDown2         → 0/423 files require formatting
swiftlint lint --strict MacDown2    → 0 violations, 0 serious, 423 files

swift build && swift test --no-parallel (MacDownKit)
  → 1121 tests in 124 suites passed

xcodegen generate
xcodebuild -scheme MacDown2 -destination 'platform=macOS' build                          → BUILD SUCCEEDED
xcodebuild -scheme macdown2 -destination 'platform=macOS' build                          → BUILD SUCCEEDED
xcodebuild -scheme MacDown2 -configuration Release -destination 'platform=macOS' build   → BUILD SUCCEEDED
xcodebuild -scheme macdown2 -configuration Release -destination 'platform=macOS' build   → BUILD SUCCEEDED
xcodebuild -scheme MacDown2 -destination 'platform=macOS' -enableCodeCoverage NO build-for-testing
  → TEST BUILD SUCCEEDED

xcodebuild -only-testing:MacDown2Tests -parallel-testing-enabled NO test-without-building
  → 113 tests in 15 suites passed (was 111 before this pass)

Real counts from the runs actually executed against this head.

Residual, honestly unverified: the manual UI matrix in the PR description — including the two new items this pass adds (palette Save's destination panel for an untitled/unavailable-backed document from origin A; New Tab/Open/Open Folder hidden with no document origin) — was not driven interactively. Not inferred passed.

This PR remains a Draft. This pass's own review explicitly called for another independent hostile pass afterward, not only a re-check of these ten items — that has not happened yet. Zero findings is not being claimed here.

@Joncallim

Copy link
Copy Markdown
Owner Author

CI note: the first build-and-test run on bff930f failed with a crash unrelated to this pass's changes — a Precondition failed: completed mutation does not match the expected value in Neon's RangeProcessor.completeContentChanged (a third-party syntax-highlighting dependency), surfacing during the pre-existing TextFilterCoordinatorTests.filterOutputIsInsertedVerbatimEvenWhenMarkdownAssistsAreEnabled test (untouched by this pass; added in the first remediation). Not reproduced across 3 local re-runs of that suite. A CI re-run of the same commit (no code changes) passed cleanly — green run. Flagging as an apparent timing-sensitive flake in the Neon dependency rather than something introduced by this pass, in the same spirit as the separately-tracked DocumentFileMonitor CI flake — not silently re-run-until-green without disclosure.

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