Skip to content

ssh: add --keep-detached-processes so detached processes prevent the server shutdown - #6387

Open
anton-107 wants to merge 6 commits into
mainfrom
deco-28187-implement-option-as-described-in-26
Open

anton-107 wants to merge 6 commits into
mainfrom
deco-28187-implement-option-as-described-in-26

Conversation

@anton-107

@anton-107 anton-107 commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Changes

Adds --keep-detached-processes to databricks ssh connect and databricks ssh setup (dedicated clusters, default off), which lets processes detached from the SSH session (tmux, setsid, nohup) outlive the tunnel:

  • Teardown (ssh-server-bootstrap.py): when the flag is set, terminate only the server's own process group - the server and its sshd children - and then hold the job run open while any detached process is still alive. Unset, the notebook sweeps its children exactly as before.
  • Server (internal/server/): the idle-timeout path returns instead of calling os.Exit(0), and before returning it looks for detached processes. With the flag unset it logs a WARN naming them (readable via /logs), and it emits a new SshTunnelTeardownEvent.
  • Plumbing: flag → notebook base parameter → server flag → metadata.json, so a reconnect that asks for the mode against a server started without it gets a new server. Same shape as --usage-policy-id. The flag is also carried in the persisted ProxyCommand, like --max-clients and --server-timeout, so a host configured through ssh setup can ask for it - that ProxyCommand is the invocation that submits the run, and the mode is fixed at submission.
  • Narrowed the startup sweep, which matched any command line containing databricks_cli and would otherwise let a new session destroy the previous session's detached work.

Implements option A from DECO-28187.

Why

The tunnel currently destroys work users deliberately detached: on server exit the bootstrap notebook runs pkill -P on everything it parents, and since it sets PR_SET_CHILD_SUBREAPER it parents every orphan in the session - which is exactly what tmux, setsid and nohup produce.

Scoping that sweep to the server's process group is not enough on its own, and this is the part measured on real compute: survivors do live through teardown, but the notebook is the WSFS-registered process and WSFS authorizes an I/O by walking the live process tree for a registered ancestor. Once the notebook returns, survivors reparent to PID 1 and every /Workspace and /Volumes call fails with EPERM - a visible failure traded for an invisible one. So the narrowed teardown and the held-open run ship together behind one flag.

Why a boolean, bounded by --server-timeout. The hold ends when the last detached process exits; the only other bound is the run's own lifetime, which --server-timeout already sets (24h by default) and Jobs already enforces. An earlier revision of this PR took a duration instead (--keep-detached-for), which was a second budget for the same run measured from a different origin: the run timeout runs from run start, while the linger clock only started once the server exited. --keep-detached-for=24h under the default --server-timeout therefore validated but could never deliver 24h, and because Validate compared the two in isolation, the ceiling it accepted silently depended on how long the session had already run. Presence is the condition the feature actually wants, and one knob for "how long this run may live" is enough. Dropped with the duration: the negative-value check, the ceiling check, the seconds-based notebook widget, and duration matching on reconnect.

What the flag spends is time on a cluster: a RUNNING run suppresses autotermination (measured). That is why it is off by default, and why it is rejected for serverless, where the container teardown takes survivors regardless. Because the hold now has no deadline, the notebook's "holding the run open" line is throttled to one every five minutes while the set of survivors is unchanged, instead of one per 15s poll for as long as the work lives.

The teardown warning is useful on its own: it turns "my work vanished with no explanation" into an actionable message even for users who never set the flag.

SshTunnelTeardownEvent is a new event rather than fields on SshTunnelEvent, so teardown rows can't be counted as connections by existing is_success queries. It needs a matching lumberjack proto in universe (follow-up). Only the request is reported, not how long the hold lasted: the notebook outlives every Go process in the session, so no CLI process can observe when the hold ends - the run's own duration carries that.

Tests

  • Unit tests for the procfs-based detection (fixture tree + one case pinned against real /proc), the teardown warning across flag/compute combinations, and the teardown telemetry asserted end-to-end through the upload path.
  • Extended existing tests for flag validation, ToProxyCommand, the submit-run base parameters, keepDetachedMatches, the connect event field, and the ssh setup host config.
  • Acceptance: the three acceptance/ssh connect goldens carry the new base parameter, and acceptance/ssh/setup pins the flag in the ProxyCommand ssh setup writes.
  • ./task lint, ./task fmt, ./task ws, go vet, ruff format/check on the bootstrap, and the full experimental/ssh + libs/telemetry unit suites pass.

Live verification. The feature was verified end to end on two dedicated dogfood clusters (results, findings): detached work survives the tunnel, the run is held open, WSFS stays intact for the whole hold, tmux attach works on the next connection, and with the flag unset teardown is indistinguishable from the old build. Two defects came out of those runs and are fixed here:

  • The hold never released early. detached_descendants() asked ps for this process's children, but ps is itself one of them and subprocess.run leaves it in the notebook's process group, so it passed the "outside the server's group" filter on every poll. The survivor list was never empty, so the run - and the cluster - was held for the full duration even with zero detached work. It now reads /proc directly, mirroring detachedDescendants in internal/server/descendants.go, which counted correctly for exactly this reason.
  • A server handover marked the previous run FAILED. A new session's bootstrap terminates a server already running on the cluster, which a reconnect asking for a different mode reaches on a normal path; the previous notebook saw -15 and raised. A SIGTERM exit is now a handover rather than a failure.

That verification ran while the flag was still a duration. Everything it exercised - group-scoped teardown, the run held open, WSFS intact for the whole hold, tmux attach on reconnect, teardown unchanged with the flag unset - is untouched by the rename; what changed is how the hold ends (the work exiting, or the run timing out, instead of a preset deadline). Not re-verified on live compute after the /proc and handover fixes, or after the rename.

This pull request and its description were written by Isaac.

@github-actions

github-actions Bot commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Approval status: pending

/libs/telemetry/ - needs approval

4 files changed
Suggested: @simonfaltum
Also eligible: @parthban-db, @renaudhartert-db, @Divyansh-db, @hectorcast-db, @tanmay-db, @tejaskochar-db, @mihaimitrea-db, @chrisst, @rauchy

General files (require maintainer)

30 files changed
Based on git history:

  • @rclarey -- recent work in libs/telemetry/protos/, experimental/ssh/internal/client/, experimental/ssh/internal/server/

Any maintainer (@andrewnester, @denik, @pietern, @shreyas-goenka, @simonfaltum, @renaudhartert-db, @janniklasrose, @lennartkats-db, @rugpanov, @rclarey) can approve all areas.
See OWNERS for ownership rules.

@eng-dev-ecosystem-bot

eng-dev-ecosystem-bot commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator

Integration test report

Commit: 8e3b76f

Run: 35442224483

Env ✅​pass 🙈​skip Time
✅​ aws linux 276 19 3:49
✅​ aws windows 278 17 4:05
✅​ azure linux 275 19 3:54
✅​ azure windows 277 17 3:42
✅​ gcp linux 276 19 3:49
✅​ gcp windows 278 17 5:11
Top 3 slowest tests (at least 2 minutes):
duration env testname
5:08 gcp windows TestAccept
4:03 aws windows TestAccept
3:40 azure windows TestAccept

anton-107 added a commit that referenced this pull request Aug 27, 2026
…dover

Fixes the two defects found verifying #6387 on real compute (DECO-28187).

1. The linger never released early. `detached_descendants()` asked `ps` for this
process's children, but `ps` is itself one of them and `subprocess.run` leaves it in
the notebook's process group, so it passed the "outside the server's group" filter on
every poll. The survivor list was never empty: `--keep-detached-for` held the run - and
suppressed cluster autotermination - for its full duration even with zero detached work,
and "No detached processes left, releasing the run" was unreachable. It now reads
`/proc` directly, mirroring `detachedDescendants` in `internal/server/descendants.go`,
which counted correctly for exactly this reason.

2. A server handover marked the previous run FAILED. A new session's bootstrap
terminates a server already running on the cluster, which a reconnect asking for a
different `--keep-detached-for` now reaches on a normal path; the previous notebook saw
-15 and raised, so its run ended INTERNAL_ERROR/FAILED. A SIGTERM exit is now treated as
a handover rather than a failure. The comment beside `needNewServer` claimed the
displaced server "idles out via shutdownDelay" - it is killed; corrected.

Also documents what the verification measured about the feature's real ceiling: the work
lives at most the linger plus the cluster's autotermination window, reconnecting after
the linger leaves survivors with EPERM on every workspace path, and a reconnect that
omits the flag inherits the linger of the server it reuses.

Verified locally under a PR_SET_CHILD_SUBREAPER parent with a real `setsid` child: the
old code reports a phantom survivor with a fresh pid each call and never empties, the new
code reports the one real detached process and empties as soon as it exits. `./task
test-exp-ssh` (327 unit + 7 acceptance), `./task lint-q`, ruff format and the whitespace
check pass.

Co-authored-by: Isaac <no-reply@databricks.com>
anton-107 and others added 2 commits September 16, 2026 19:05
Today the SSH tunnel destroys work that was deliberately detached. When the server
exits on its idle timer, the bootstrap notebook sweeps every process it parents with
`pkill -P`, and because it sets PR_SET_CHILD_SUBREAPER it parents every orphan in the
session - which is exactly what tmux, setsid and nohup make of themselves.

Scoping that sweep to the server's own process group is not enough on its own, and
that is the part measured on real compute (DECO-28187): survivors do live through the
teardown, but the notebook is the WSFS-registered process, and WSFS authorises an I/O
by walking the live process tree for a registered ancestor. The moment the notebook
returns, the survivors reparent to PID 1 and every /Workspace and /Volumes call fails
with EPERM. That turns a visible failure into an invisible one.

So the two halves ship together, behind one flag:

    databricks ssh connect --cluster=<id> --keep-detached-for=<duration>

With it set, teardown terminates only the server's process group and the notebook then
holds the job run open while any detached process is still alive, so the anchor stays
in place. With it unset - the default - the notebook sweeps its children exactly as
before, and the server logs a warning naming the processes it is about to destroy, so
the original complaint (work vanished with no explanation) becomes actionable even for
users who never set the flag.

A duration rather than a boolean, because what the feature spends is time on a
cluster: a held-open run suppresses autotermination. It is capped at the run's own
timeout, rejected for serverless (the container teardown takes survivors regardless),
and plumbed like --usage-policy-id: notebook base parameter, persisted in
metadata.json, and a reconnect asking for a different value starts a new server.

Telemetry: the connect event records whether the flag was asked for. Whether the
session actually left detached work behind is only visible on the compute at teardown,
so the server emits a new SshTunnelTeardownEvent for it - separate from SshTunnelEvent
so it cannot be counted as a connection. That number is what tells us how often we
destroy work today, and so whether the default should ever flip.

Also narrows the startup sweep, which matched any command line containing
`databricks_cli` and would otherwise let a new session destroy a previous session's
detached work.

Co-authored-by: Isaac <no-reply@databricks.com>
…dover

Fixes the two defects found verifying #6387 on real compute (DECO-28187).

1. The linger never released early. `detached_descendants()` asked `ps` for this
process's children, but `ps` is itself one of them and `subprocess.run` leaves it in
the notebook's process group, so it passed the "outside the server's group" filter on
every poll. The survivor list was never empty: `--keep-detached-for` held the run - and
suppressed cluster autotermination - for its full duration even with zero detached work,
and "No detached processes left, releasing the run" was unreachable. It now reads
`/proc` directly, mirroring `detachedDescendants` in `internal/server/descendants.go`,
which counted correctly for exactly this reason.

2. A server handover marked the previous run FAILED. A new session's bootstrap
terminates a server already running on the cluster, which a reconnect asking for a
different `--keep-detached-for` now reaches on a normal path; the previous notebook saw
-15 and raised, so its run ended INTERNAL_ERROR/FAILED. A SIGTERM exit is now treated as
a handover rather than a failure. The comment beside `needNewServer` claimed the
displaced server "idles out via shutdownDelay" - it is killed; corrected.

Also documents what the verification measured about the feature's real ceiling: the work
lives at most the linger plus the cluster's autotermination window, reconnecting after
the linger leaves survivors with EPERM on every workspace path, and a reconnect that
omits the flag inherits the linger of the server it reuses.

Verified locally under a PR_SET_CHILD_SUBREAPER parent with a real `setsid` child: the
old code reports a phantom survivor with a fresh pid each call and never empties, the new
code reports the one real detached process and empties as soon as it exits. `./task
test-exp-ssh` (327 unit + 7 acceptance), `./task lint-q`, ruff format and the whitespace
check pass.

Co-authored-by: Isaac <no-reply@databricks.com>
@anton-107
anton-107 force-pushed the deco-28187-implement-option-as-described-in-26 branch from c6560ba to 5df516e Compare September 16, 2026 19:10
anton-107 and others added 2 commits September 17, 2026 21:07
Replaces --keep-detached-for=<duration> with --keep-detached-processes, a
boolean bounded by the run's own timeout (--server-timeout, 24h by default).

The duration was a second budget for the same run, measured from a different
origin: the run timeout runs from run start, while the linger clock only
started once the server exited. --keep-detached-for=24h under the default
--server-timeout therefore validated but could never deliver 24h, and since
Validate compared the two in isolation, the ceiling it accepted silently
depended on how long the session had already run. Presence is the condition the
feature wants - hold the run while detached work is alive - and
--server-timeout already means "how long this run may live", so it can carry
the cost control on its own.

Dropped along with the duration: the negative-value check, the ceiling check,
the seconds-based notebook widget, and duration matching on reconnect.
keepDetachedMatches is now "asking for it needs a server that has it".

The notebook's hold has no deadline any more, so its per-poll log line is
throttled to one every five minutes while the set of survivors is unchanged,
instead of every 15 seconds for as long as the work lives.

Co-authored-by: Isaac <no-reply@databricks.com>
The mode is fixed when the server job is submitted, and for a host configured
by `ssh setup` the submitting invocation is always the persisted ProxyCommand -
the same reason --max-clients and --server-timeout are carried there. Without
this, an `ssh <name>` session could never ask to keep its detached work: it
either inherited the mode from a server someone had already started with
`ssh connect --keep-detached-processes`, or started one that swept the work.

Setup already validates the ClientOptions it serializes, so the dedicated-only
check applies here too; setup requires a cluster, so it cannot trip.

Co-authored-by: Isaac <no-reply@databricks.com>
@anton-107 anton-107 changed the title ssh: add --keep-detached-for so detached processes survive the tunnel ssh: add --keep-detached-processes so detached processes survive the tunnel Sep 17, 2026
## Changes

Add a non-reaping `waitid` check before releasing the SSH bootstrap run,
plus six regression scenarios wired into the SSH Go test suite.

## Why

Targets #6387. After SIGTERM, server-group helpers can still parent
detached work that the notebook has not adopted yet. An empty survivor
scan must not release the run while children remain. The existing
SIGCHLD handler retains ownership of exit statuses, and polling/report
throttling stay unchanged.

## Tests

- Regression cases fail on the unpatched parent (`b3c4b6c3c`) and pass
with this fix. An isolated Linux process reproduction also verifies
delayed adoption and exit-status preservation.
- `./task fmt`, `./task checks`, `./task lint`, and `./task
test-exp-ssh` pass.
- `./task test` passes with `OMNIGENT` unset, cloud testing disabled,
and an isolated `TMPDIR`; two unrelated Terraform acceptance timeouts
pass on automatic retry.

_This PR was written by Codex._

---
<!-- GITHUB_MCP_FOOTER: This attribution is automatically appended by
GitHub MCP. -->
_This PR was created with [GitHub MCP](http://go/mcps)._
Comment on lines +151 to +152
// On serverless the container goes away with the run, so nothing survives the server
// however long the notebook holds the run open.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't follow. You say the container goes away with the run, but doesn't that mean holding the run open keeps the container?

Also this seems like a completely different reason compared to the design doc for why --keep-detached-processes is only supported on dedicated clusters

Comment on lines +14 to +19
module = ast.parse(source.read_text())
module.body = [
node
for node in module.body
if isinstance(node, ast.FunctionDef) and node.name in {"has_children", "wait_for_detached_descendants"}
]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems a bit hacky, can we export those functions and test them normally instead?

Comment thread experimental/ssh/README.md Outdated
Comment on lines +87 to +88
the cost is what matters; multi-day work still belongs in Jobs/DABs. Note also that
reconnecting starts a new run rather than rejoining the one being held open, so each session

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we shutdown the server while detached processes are still running? Won't people want to reconnect to their tmux sessions they left running?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

--server-timeout remains to be an ultimate kill switch to keep runaway workloads at cap budget and it is a setting on the job level. This PR does not change that

@anton-107 anton-107 changed the title ssh: add --keep-detached-processes so detached processes survive the tunnel ssh: add --keep-detached-processes so detached processes prevent the server shutdown Sep 19, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants