Skip to content

refactor(cli): unify CLI process execution behind one vscode-free seam - #2148

Open
rugpanov wants to merge 4 commits into
mainfrom
unify-cli-execution
Open

refactor(cli): unify CLI process execution behind one vscode-free seam#2148
rugpanov wants to merge 4 commits into
mainfrom
unify-cli-execution

Conversation

@rugpanov

Copy link
Copy Markdown
Contributor

What

Both the cli and python-setup folders carried their own child-process plumbing for the same job. This unifies them behind one vscode-free execution seam that every request/response CLI call flows through.

  • New src/cli/cliProcess.tsrun(command, args, options), carrying zero vscode imports so it is fully unit-testable via injected spawn/terminate seams. It:
    • spawns detached on POSIX (own process group) and kills the whole process tree on cancel (taskkill /T /F on Windows; SIGTERM to the negated pid on POSIX),
    • buffers-then-decodes output so a multi-byte UTF-8 char split across two data events can't corrupt the payload, and streams decoded chunks to onStdout/onStderr,
    • resolves on any exit — exit-code policy belongs to the caller — and rejects only on a genuine spawn/stream error.
  • CliWrapperexecFile / cancellableExecFile / runBundleCommand are now thin adapters over run(). The Node-execFile error surface (stderr in .message, plus .code/.stdout/.stderr) is preserved so the SDK's isFileNotFound and the profile-parse substring checks keep working. waitForProcess and the duplicated Windows-escaping helper are removed.
  • PythonSetupCliClient — delegates its spawning to run() and keeps only the setup-local concern (argv, JSON parse, onLog, cancel→PythonSetupCancelledError). The shared primitives (terminateProcessTree, CancellationLike, seams) now live in cliProcess and are re-exported for existing consumers.

Scope boundary

The interactive pty terminals (bundle run via CustomOutputTerminal, bundle sync --watch via SyncTasks) are deliberately left out — they render into a live Pseudoterminal (ANSI, session-long), a fundamentally different execution model from request/response. ProcessError/CancellationError stay in CliWrapper because their toast touches vscode, which keeps cliProcess import-free.

Bonus correctness (picked up by the unification)

  • POSIX cancel now tears down the whole tree — no more orphaned terraform/uv grandchildren (the old CliWrapper path SIGTERM'd only the direct child).
  • The buffered path no longer has Node execFile's 1 MB stdout cap.

Backward compatibility

No user-facing change and no new setting/state/telemetry/when-clause. Every existing exec contract is preserved: buffered error surface, closeStdin, {shell:true} passthrough, bundle logger narration + commands.executeCommand("…showLogs"), and PythonSetupCliClient's resolve-on-both-exits + cancel semantics. Windows behavior is unchanged per caller: the buffered/bundle paths keep cmd.exe escaping; setup-local keeps spawning the resolved binary directly.

Verification

  • New cliProcess.test.ts (21 cases): buffered capture, exit codes, streaming, UTF-8 straddle, partial-byte flush, closeStdin, shell passthrough, spawn error, cancel→tree-kill, and Windows escaping — all via injected fakes, run under plain mocha (no extension host). PythonSetupCliClient (16 cases) and CliWrapper suites stay green with the adapters unchanged.
  • yarn test:unit930 passing, 0 failing. yarn test:lint → clean.

This pull request and its description were written by Isaac.

*Why*

The `cli` and `python-setup` packages each carried their own child-process
plumbing: `CliWrapper` had `execFile`/`waitForProcess`/`runBundleCommand`
(buffered exec + bundle streaming, cancelled via an AbortController that only
SIGTERMs the direct child) while `PythonSetupCliClient` had a separate,
better spawn core (detached process group, whole-tree kill, buffer-then-decode).
Two implementations of the same concern, and the older one leaked grandchildren
(`terraform`, `uv`) on cancel.

*What*

- Add `src/cli/cliProcess.ts`: a single, vscode-free execution seam
  (`run(command, args, options)`) that every request/response CLI call flows
  through. Injectable spawn/terminate seams make it fully unit-testable. It
  spawns detached on POSIX + kills the whole process tree on cancel (taskkill
  /T /F on Windows), buffers-then-decodes output (no split-code-point
  corruption), streams decoded chunks to callbacks, and resolves on any exit
  (exit-code policy is the caller's).
- `CliWrapper`'s `execFile`/`cancellableExecFile`/`runBundleCommand` are now thin
  adapters over `run()`; the Node-`execFile` error surface (stderr in `.message`,
  `.code`/`.stdout`/`.stderr`) is preserved so `isFileNotFound` and the
  profile-parse checks keep working. `waitForProcess` and the duplicated Windows
  escaping are removed.
- `PythonSetupCliClient` delegates its spawning to `run()` and keeps only the
  setup-local concern (argv, JSON parse, onLog, cancel mapping); the shared
  process primitives (`terminateProcessTree`, `CancellationLike`, seams) now live
  in `cliProcess` and are re-exported for existing consumers.
- The interactive pty terminals (`bundle run`, `bundle sync --watch`) are
  deliberately left out — they render into a live Pseudoterminal, a different
  execution model.
- `ProcessError`/`CancellationError` stay in `CliWrapper` (they touch `vscode`
  for their toast), keeping `cliProcess` free of any `vscode` import.

Bonus: POSIX cancel now tears down the whole tree (no orphaned terraform/uv),
and the buffered path no longer has Node execFile's 1 MB stdout cap.

*Verification*

- New `cliProcess.test.ts` (21 cases): buffered capture, exit codes, streaming,
  UTF-8 straddle, partial-byte flush, closeStdin, shell passthrough, spawn error,
  cancel→tree-kill, Windows escaping — all via injected fakes, run under plain
  mocha (no extension host).
- `yarn test:unit` → 930 passing, 0 failing (the one `shellUtils` real-shell
  timeout was a load flake; green on rerun). `yarn test:lint` → clean.

Co-authored-by: Isaac <no-reply@databricks.com>
@rugpanov
rugpanov deployed to test-trigger-is August 24, 2026 11:19 — with GitHub Actions Active
@rugpanov
rugpanov deployed to test-trigger-is August 24, 2026 11:20 — with GitHub Actions Active
@rugpanov

Copy link
Copy Markdown
Contributor Author

🤖 Integration tests triggered for 100b19c4 — ⏳ running.
View run

*Why*

Review of the cliProcess seam surfaced two real robustness gaps introduced by
routing every call through spawn + a cancellation token:
- cancel could hang: the run resolved only on "close", so a child that ignored
  SIGTERM (or a failed taskkill) left bundle/aitools/python-setup operations
  pending forever. The old buffered execFile path settled promptly on cancel.
- a stdout/stderr stream error rejected without terminating the child; since
  POSIX children are now spawned detached, the tree kept running after the
  caller saw a failure.

*What*

- On cancel, `run()` now fires the terminate and settles immediately as
  cancelled instead of waiting for "close"; a late "close" is ignored. Removes
  the hang while keeping the whole-tree kill.
- On a stdout/stderr "error", `run()` terminates the process tree before
  rejecting (spawn-level "error" still just rejects — there is no process yet).
- Forward-declared the cancellation subscription so a synchronously-firing
  token can't hit a temporal-dead-zone in `finish`.

*Verification*

- New `cliProcess.test.ts` cases: "settles promptly on cancel even if the
  process never closes" and "terminates the process tree when a stdout stream
  error occurs". New `CliWrapper.test.ts` case pins the buffered non-zero-exit
  error surface (`.code`/`.stderr`/`.stdout` + stderr in `.message`) that
  `isFileNotFound` and the profile parser depend on.
- `yarn test:unit` -> 933 passing, 0 failing. `yarn test:lint` -> clean.

Co-authored-by: Isaac <no-reply@databricks.com>
@rugpanov
rugpanov deployed to test-trigger-is August 24, 2026 11:37 — with GitHub Actions Active
@rugpanov
rugpanov deployed to test-trigger-is August 24, 2026 11:39 — with GitHub Actions Active
@rugpanov

Copy link
Copy Markdown
Contributor Author

🤖 Integration tests triggered for f67b0e26 — ⏳ running.
View run

*Why*

Second review pass flagged two issues in the cancellation path:
- settling on cancel without confirming teardown could leave a detached CLI
  alive (and its capture listeners buffering) after the caller moved on, while
  waiting only for "close" could hang if the child ignored SIGTERM.
- a token that fires its callback synchronously on subscription settled the run
  before the subscription handle was assigned, so it was never disposed (a
  listener leak); the synchronous terminate could also close the child before
  the "close" listener was even attached, dropping the event.

*What*

- Cancel now terminates gracefully (SIGTERM tree), waits for "close", and after
  a bounded grace escalates to SIGKILL (which the process cannot ignore). The
  run always settles, and only after a confirmed teardown, so no orphaned tree.
  Grace period and both terminators are injectable seams.
- Subscribe to the cancellation token only after the child listeners
  (especially "close") are attached, and dispose the subscription explicitly
  when a synchronous token already settled the run.

*Verification*

- New cliProcess.test.ts cases: escalates to force kill when the graceful
  terminate is ignored (then settles on close); does not escalate when the
  graceful terminate closes the process; disposes the subscription when the
  token fires synchronously; terminateProcessTree honours a SIGKILL signal.
- yarn test:unit -> 936 passing, 0 failing. yarn test:lint -> clean.

Co-authored-by: Isaac <no-reply@databricks.com>
@rugpanov
rugpanov deployed to test-trigger-is August 24, 2026 11:50 — with GitHub Actions Active
@rugpanov

Copy link
Copy Markdown
Contributor Author

🤖 Integration tests triggered for a1f629ac — ⏳ running.
View run

@rugpanov
rugpanov deployed to test-trigger-is August 24, 2026 11:52 — with GitHub Actions Active
…t loop

*Why*

The fire-and-forget taskkill spawned to tear down the process tree on Windows
was never unreferenced, so it could keep the event loop (and the test runner)
alive across repeated cancellations.

*What*

- `unref()` the spawned taskkill helper in the real terminate primitives; still
  swallow its async spawn error as before.

*Verification*

- `yarn test:unit` -> 936 passing, 0 failing. `yarn test:lint` -> clean.
  (The helper is the real OS primitive; tests drive termination through the
  injected fake, so there is no unit-level behavior change to assert.)

Co-authored-by: Isaac <no-reply@databricks.com>
@rugpanov
rugpanov deployed to test-trigger-is August 24, 2026 12:14 — with GitHub Actions Active
@rugpanov
rugpanov marked this pull request as ready for review August 24, 2026 12:14
@github-actions

Copy link
Copy Markdown
Contributor

If integration tests don't run automatically, an authorized user can run them manually by following the instructions below:

Trigger:
go/deco-tests-run/vscode

Inputs:

  • PR number: 2148
  • Commit SHA: 7db62478d7d5a14e4a87376f811cc57f1a350fca

Checks will be approved automatically on success.

@rugpanov

rugpanov commented Aug 24, 2026

Copy link
Copy Markdown
Contributor Author

🤖 Integration tests ✅ all 41 test jobs passed for 7db62478.
View run

@rugpanov
rugpanov deployed to test-trigger-is August 24, 2026 12:15 — with GitHub Actions Active
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