Skip to content

Background tasks core: supervised PTY tasks, detection, preferences, bounded output (#382) - #383

Closed
raiseCatError wants to merge 5 commits into
release/v0.18.0from
feature/background-tasks-core
Closed

raiseCatError wants to merge 5 commits into
release/v0.18.0from
feature/background-tasks-core

Conversation

@raiseCatError

@raiseCatError raiseCatError commented Oct 11, 2026 •

Copy link
Copy Markdown
Owner

First slice of #382. Standalone modules in src/backgroundTasks/; no shared files changed (TerminalApp, session service and protocol are untouched — the integration contract is in docs/design/background-tasks.md). Based on release/v0.18.0.

Architecture. The real shell runs one foreground job; a background task is an NMSh-owned process in its own PTY and process group, run as <shell> -c <command> with the command as one argv element (exact quoting preserved, tested). It inherits the session's launch environment, the live PATH and cwd — not live aliases, functions, unexported variables or options (stated in the doc and, in the UI PR, at the point of choice). A running foreground command is never moved, killed or re-run.

  • supervisor.ts: stop = SIGINT to the task's group (Ctrl+C semantics, not a 0x03 byte), explicit SIGTERM/SIGKILL escalation; stopping until the exit is observed; NMSh's shell, other tasks and detached helpers (adb-server style) never signalled; leftover group members reported and killed only on request; remove only for finished tasks; environment never on records; running-task limit; coalesced notifications.
  • detector.ts: conservative family recognition (adb logcat ≠ adb logcat -d ≠ adb devices, tail -f ≠ tail, npm run dev = "likely", npm test not); plain pipelines only; never substitutions, redirections, lists, sudo/ssh or full-screen programs; nothing executed.
  • preferences.ts: per-family Always/Never/Ask, no global always-background, private storage without commands or output; decide() backgrounds only on explicit Always; per-invocation foreground override.
  • output.ts: sanitized, numbered, bounded lines; progress lines; PTY \r\r\n (a real bug the tests caught).

Tests (real PTYs, controlled programs mimicking logcat/tail -f/scrcpy/adb server): tests/backgroundSupervisor.test.ts, tests/backgroundDetector.test.ts.

Next: stacked UI PR (task bar, keys, suggestion strip, /background manager model, task output view); integration (service protocol + TerminalApp) after #359's checkpoint. Physical QA with real adb/scrcpy is listed and not performed.

Update (fd49bfa), from Linux CI: Ubuntu's /bin/sh (dash) doesn't exec the command, so SIGINT ended the wrapper shell while a program ignoring SIGINT kept running, and the task was reported stopped too early. After a stop request the task now stays stopping, with escalation still reaching its group, until the whole group is gone. A new test reproduces the dash case on macOS and fails against the old code.

Update (4b9685d), quality and security pass: output cleaning trimmed trailing \r with /\r+$/, quadratic on a run of carriage returns (128 Ki took 3.7 s and froze the reader); trimmed by hand now. Leftover group members were checked only twice after the leader exited, so a longer-lived member kept "processes left" forever and a later kill could reach a reused group id; the group is now probed every 250 ms until empty, then forgotten (the doc's earlier "signals only reach unreaped leaders" claim was inaccurate and is corrected). A finished task whose processes still run is no longer removable. Stop requests record stopRequestedAt so views can pace escalation. Each fix has a regression that fails without it. CI green on 4b9685d (macOS and Ubuntu shards, Node 22, Quality, Verify).

…preferences and bounded output

#382, standalone modules; the running app is unchanged until integration.

- supervisor.ts: each task is an NMSh-owned process in its own PTY,
  session and process group, started as `<shell> -c <command>` with the
  command as one argument (never re-quoted or parsed). Stop is SIGINT to
  the task's group (what Ctrl+C does), with SIGTERM and SIGKILL as explicit
  escalations; states are stopping until the exit is observed, then
  stopped/exited/failed. NMSh's shell, other tasks and detached helpers
  (an adb server) are never signalled. Group members left after the leader
  exits are reported and killed only on request. Remove is for finished
  tasks only. The environment is used for the spawn and never kept.
- detector.ts: recognizes invocations whose words say they keep running
  (adb logcat but not adb logcat -d or adb devices, tail -f but not tail,
  npm run dev as "likely", npm test not), plain pipelines only; never
  substitutions, redirections, lists, sudo/ssh or full-screen programs.
- preferences.ts: per-family Always/Never/Ask, no global always-background
  mode, retention and notification settings, stored privately without
  commands or output; decide() backgrounds only on an explicit Always.
- output.ts: per-line sanitization with stripTerminalControls, progress
  lines keep their last state, PTY "\r\r\n" handled, bounded by lines and
  bytes with dropped and received counts.

docs/design/background-tasks.md records the architecture choice, what a
task inherits and does not, lifecycle under the session service, the
protocol and frontend contract, a security review and physical QA.
Linux CI showed it: Ubuntu's /bin/sh (dash) does not exec the command, so
SIGINT ended the wrapper shell while a program that ignores SIGINT kept
running, and the task was reported stopped with processes left. After a
stop request the task now stays `stopping`, and escalation still reaches
its group, until the whole group is gone. The tests accept either exit form
of the leader (exit 130 from the program itself, or signal 2 from a wrapper
shell) and reproduce the dash case on macOS with a command the shell cannot
exec.
…so the kill path is exercised where /bin/sh is dash

On Ubuntu, SIGTERM ends the dash wrapper; the kernel then hangs up the
task's group and the program, which did not ignore SIGHUP, ended there. That
stop was real and reported truthfully; the test now keeps the program alive
through the hangup so SIGKILL escalation is what ends it on every platform.
…gup, as a program detached from it would

Linux CI: once the dash wrapper exits, the terminal is hung up and the
fixture's next write failed with EIO, ending it through an unhandled stream
error before SIGKILL was sent. The fixture now ignores write errors along
with SIGHUP, and the assertion reports the task and its last lines if it
ever differs again.
…til it is gone, never removed while it runs

- A run of carriage returns made the trailing-\r regular expression quadratic: 128 Ki of them took 3.7 s and froze
  the process reading the task. They are trimmed by hand now.
- Leftover group members were checked twice after the leader exited; one that lived longer kept "processes left"
  forever, and a later kill request could reach a reused group id. The group is probed until it is empty, then
  forgotten.
- A finished task whose processes still run is not removed: nothing could stop them afterwards.
- Stop requests record when they were made, so views can pace escalation.
raiseCatError added a commit that referenced this pull request Oct 11, 2026
…sk quality and security pass

New heads for #375-#378, #383 and #384; a prioritized findings table with the fixes and their commits; the
investigated locations that are not vulnerabilities (the demo renderer, configAssist.ts:164, the session socket, Git
in untrusted repositories, dependencies); what cannot be validated before integration; and the contract changes
Codex needs at integration, including one precedence for Up from an idle composer.
raiseCatError added a commit that referenced this pull request Oct 14, 2026
Each session owns its background tasks beside its shell, in the session
service or in the in-process client: SessionTasks wraps the #383
supervisor, tasks keep running while a window is detached, a reattached
window receives the list and each task's recent output (never a restart),
and ending the session ends its tasks. A shell switch keeps them.

Protocol: the background-tasks feature on both sides; task (start, stop,
remove) from the frontend; tasks and task-output from the owner, only to
a frontend that listed the feature and only after created/attached and
the replay. Every request, record and update is validated on arrival.
Task output travels separately from the shell's stream and never
reaches its PTY; an update carries at most 1 MiB, newest lines first.

A start carries the live shell's PATH; the owner supplies the session
shell and its launch environment, which is used for the spawn only.
TaskOutput gains receive() so a frontend mirror keeps the owner's line
numbers under the same limits.
@raiseCatError

Copy link
Copy Markdown
Owner Author

Integrated, not merged here: this head (4b9685d3) is part of the release line that shipped in v0.20.0 through the authoritative release PR #392 (merge commit 65e4de55). Closing because its changes reached master by that route; the branch is kept for history.

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