Skip to content

Make remote submission over SSH work, from the host and from the container - #1001

Open
calvinp0 wants to merge 9 commits into
mainfrom
fix_remote_job_lifecycle
Open

Make remote submission over SSH work, from the host and from the container#1001
calvinp0 wants to merge 9 commits into
mainfrom
fix_remote_job_lifecycle

Conversation

@calvinp0

Copy link
Copy Markdown
Member

Makes ARC's remote-cluster path work over SSH, and then makes it work from inside the Docker image.

SSH

A server's configured key was handed to load_system_host_keys() rather than to connect(), so it was never offered as an authentication identity. It is now passed as key_filename, and made optional so a forwarded ssh-agent or paramiko's default key paths can authenticate where no local key file exists.

The same path stops silently trusting unknown hosts — AutoAddPolicy becomes WarningPolicy, with an opt-in per-server strict_host_key_checking selecting RejectPolicy — and narrows a bare except around the connect retry that had been swallowing Ctrl-C and discarding the real authentication error.

A process-global connection pool replaces one-Transport-per-job, so a TS search no longer opens ~100 connections to a single cluster; teardown is wired into ARC's exit. Pipe mode now refuses a remote server, and says why, instead of deadlocking on a submission that can never land. Orca NEB resolves its reactant/product geometry paths on the executing machine.

Reuse check: searched main for existing pooling or connection reuse (pool, reuse.*connect, persistent.*connect, ControlMaster across arc/job/ssh.py and arc/job/adapter.py) — none exists, and SSHClient() is constructed in exactly one place (arc/job/ssh.py:371). arc/job/ssh_pool.py is genuinely new rather than a third copy.

Docker

Adds agent-socket forwarding and read-only key / ~/.arc mounts; fixes a PUID/PGID remap that aborted the container for most Linux desktop users; stops a chmod o+rw that was widening the user's live host agent socket; and adds a pre-flight that fails fast when a mounted settings overlay is unimportable, instead of letting ARC run silently against its dummy server1.host.edu fixtures.

Structure

Six commits, no file touched by more than one — the two stories partition cleanly by path (SSH owns arc/** + ARC.py; Docker owns Dockerfile, docker-compose.yml, dockerfiles/**, docs/**), with only documentation cross-references coupling them.

Verification

Rebased onto main (d033cbbf) from 107 behind, replayed with zero conflicts. The rebase and the subsequent 13→6 restructure were both checked by content identity, not just by tests: the changed-line multiset of old-base..old-head versus new-base..new-head is identical in both directions (2453 lines each), and the restructure left the tree bit-for-bit unchanged. Tests cannot prove a rebase didn't drop a hunk; this can.

249 passed across ssh_test, ssh_pool_test, pipe/, adapter_test, settings. The broader arc/job/ sweep shows the same 5 pre-existing torch_ani_test environmental failures before and after — zero new.

🤖 Generated with Claude Code

Copilot AI lite review requested due to automatic review settings August 19, 2026 15:53

Copilot AI left a comment

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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Comment thread arc/job/ssh.py Fixed
Comment thread arc/job/ssh_pool_test.py Fixed
Comment thread arc/job/adapter_test.py Fixed
Comment thread arc/job/ssh_pool_test.py Fixed
Comment thread arc/job/ssh_pool_test.py Fixed
Comment thread arc/job/ssh_test.py Fixed
@codecov

codecov Bot commented Aug 19, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 65.38%. Comparing base (09e9ede) to head (db2a20a).
⚠️ Report is 1 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1001      +/-   ##
==========================================
+ Coverage   64.76%   65.38%   +0.61%     
==========================================
  Files         119      120       +1     
  Lines       40039    40303     +264     
  Branches    10350    10390      +40     
==========================================
+ Hits        25932    26352     +420     
+ Misses      11129    10944     -185     
- Partials     2978     3007      +29     
Flag Coverage Δ
functionaltests 65.38% <ø> (+0.61%) ⬆️
unittests 65.38% <ø> (+0.61%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@calvinp0
calvinp0 force-pushed the fix_remote_job_lifecycle branch from 6198a03 to ec9d9c7 Compare August 19, 2026 17:29
Comment thread arc/job/ssh.py Fixed

Copilot AI left a comment

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.

Pull request overview

Copilot reviewed 25 out of 25 changed files in this pull request and generated 1 comment.

Comment thread arc/job/ssh.py Outdated
Comment on lines +550 to +554
command = f'rm -r "{remote_path}"'
_, stderr = self._send_command_to_server(command)
if stderr:
raise ServerError(
f'Cannot remove dir for the given path ({remote_path}).\nGot: {stderr}')

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

adopted. also found seven other sites that required this change.

@calvinp0
calvinp0 force-pushed the fix_remote_job_lifecycle branch from ec9d9c7 to fa404f1 Compare August 19, 2026 19:37
@alongd

alongd commented Aug 20, 2026

Copy link
Copy Markdown
Member

Reviewed this at fa404f125 against d033cbbf with an agent fleet (8 passes, incl. mutation testing and a cross-model pass). Everything below was executed unless marked otherwise — repros in a read-only worktree, no jobs submitted, no connections opened.

First, the credit: this PR genuinely fixes host-key handling. Base fed the private key path to load_system_host_keys(), which yields an empty host-key store under AutoAddPolicy — ARC had zero host verification, ever. That's a real fix and it was invisible until you touched it. The pooling is correctly built, and 4 of the 5 behaviors I mutation-tested are properly guarded (dropping key_filename → 4 failures; swapping policies → 2; disabling dead-connection eviction → 2; inverting the pipe refusal → 9). The findings below are about the edges, not the direction.


The one thing worth fixing first

orca_neb.py:229-231 writes a relative path into a template Orca can't resolve. This is the PR's only chemistry-affecting hunk, and it has zero test coverage.

adapter.py:442 builds remote_path from servers[server].get('path', '') — when 'path' is absent the join collapses to a relative path. 'path' is absent from all three shipped remote server examples in settings.py and is documented only under local:

server1    remote_path='runs/ARC_Projects/proj/spc/job1'                     isabs=False
with_path  remote_path='/storage/group_name/u/runs/ARC_Projects/proj/spc/job1' isabs=True

The shipped Orca Slurm template then runs the job somewhere else entirely:

WorkDir=/state/partition1/user/{un}/$SLURM_JOB_NAME-$SLURM_JOB_ID
cd $WorkDir
cp $SubmitDir/input.in .          # only input.in is copied

So NEB_END_XYZFILE "runs/ARC_Projects/.../product.xyz" resolves from /state/partition1/..., Orca aborts, every NEB TS guess for that reaction fails, and ARC falls through to other TS methods saying nothing about why. Secondary: .lower() on line 441 silently rewrites 'path': '/Storage/Users'/storage/users.

Suggested fix: put bare filenames in the NEB template and add reactant.xyz/product.xyz to the cp $SubmitDir/... line. The files are already uploaded into the remote path, so nothing needs rendering.

And it's unguarded — I reverted abs_path to always use self.local_path (pre-PR behavior) and re-ran: 2 passed, 0 failed. orca_neb_test.py's only server= is 'local'. Worth adding a non-local server case asserting abs_path == self.remote_path, written against the corrected shape.


One root cause behind four findings

connect()'s while times_tried < 1440 loop catches Exception and sleeps 60s, turning every permanent, deterministic failure into a 24-hour silent stall. Four separate symptoms are this one defect:

  1. strict_host_key_checking: True doesn't refuse — it stalls for 24h. RejectPolicy raises a plain SSHException, which _connect catches and retries, then the outer loop swallows:
    SSHException:            outer loop result=exhausted, _connect calls=5
    AuthenticationException: outer loop result=exhausted, _connect calls=5
    
    Both the _connect docstring ("refusing any host that is not already in known_hosts") and docs/source/remote_submission.rst ("The connection is refused") describe behavior the code doesn't have.
  2. Wrong key or username → 2880 auth attempts over 24h against a login node. AuthenticationException is an SSHException subclass. Not a regression (the base bare except had this too), but narrowing the except was the moment to fix it.
  3. A host-key mismatch is newly reachable. Base never loaded real known_hosts, so BadHostKeyException was structurally impossible; now it lands in the same loop. Note get_servers_missing_host_keys (ssh.py:642) tests lookup(address) is None — it sees missing keys and cannot see a mismatch, so the startup check gives false comfort for exactly the case that now hard-fails. Concretely: a login alias round-robining to login1/login2 with distinct keys, or any rekeyed cluster. (read, not executed — 8/10)
  4. A missing key path also stalls 24h. The shipped 'key': 'path_to_rsa_key' literal triggers it, and arc_preflight.check_key classifies it non-fatal. Not a regression — base ssh.py:373 raised IOError on the same input into the same loop. But ssh.py:387's new docstring claims passing the key as key_filename "avoids paramiko raising on a configured key path that does not exist on this machine" — it doesn't; only an unset key does.

One fix covers all four: classify the exception once — re-raise AuthenticationException, BadHostKeyException, RejectPolicy rejections, and missing-key OSErrors; retry only transport/banner failures.


Two more with executed repros

download_file silently reversed its contract (ssh.py:198-206). Base always called sftp.get, and paramiko 4.0.0 opens localpath "wb" before fetching — so a missing remote file left a zero-byte local file. The base comment said so explicitly ("if the remote path does not exist an empty file will be created at the local path… to avoid screwing up ESS trsh"). The new retry loop returns before that:

BASE  d033cbbf: local err.txt after a MISSING remote file -> b''                        => cleared
HEAD  fa404f125: local err.txt after a MISSING remote file -> b'slurmstepd: error: *** JOB 12345 CANCELLED DUE TO TIME LIMIT ***'
                                                                                       => STALE CONTENT SURVIVES

adapter.py:892-908 rebuilds additional_job_info from whatever out.txt/err.txt are on disk, and determine_job_status scans that string for server-error keywords. A latched stale err.txt can mark a healthy job time-limit-errored, or mask a new failure with an old one. This gets sharper once remove_remote_files is wired up. Either restore truncate-on-miss, or delete the local target before returning early.

ARC suppresses its own host-key warning (ssh.py:428 + common.py:217 + main.py:317). WarningPolicy.missing_host_key() emits via warnings.warn(), not logging. initialize_log() installs warnings.filterwarnings(action='ignore', module='.*paramiko.*') two lines before check_servers_known_hosts(), long before any connection:

--- BEFORE ARC's filter ---   warnings captured: 1 ['Unknown ssh-rsa host key for cluster.exa']
--- AFTER  ARC's filter ---   warnings captured: 0 []

On an unknown host ARC connects and prints nothing — not to stdout, not to arc.log. The _connect docstring's justification ("logs the unknown key… the warning repeats on every connection") is false on every clause. A small MissingHostKeyPolicy subclass calling logger.warning() with the fingerprint would land the user-visible half of the improvement.


Ten more findings (click to expand) — pool coverage, dead fallback, silent-import fallbacks, pipe guard, style
  • The pool misses the hot path its docstring claims (adapter.py:268, executed). The actual per-cycle poll is Scheduler.get_server_job_ids, still with SSHClient(server) as ssh: — full TCP+auth per remote server per label, every 30s tick, plus :4386/:4391 for the max_simultaneous_jobs gate. git diff -- arc/scheduler.py is empty. Measured: unpooled SSHClient() constructions across 5 ticks: 300 vs pooled: 2. Also unpooled: trsh.py:1483,1488,1502,1524 (:1488 never closes), adapters/cfour.py:305 (overrides execute_queue, bypassing the borrow wrapper), ssh.py:744, main.py:832.
  • _open_or_borrow_ssh's "degrade gracefully" fallback is unreachable dead code (adapter.py:283-295, executed). borrow is a @contextmanager; calling it runs no body. The factory executes at __enter__, inside the caller's with, outside this try. Behavior is unchanged from pre-PR so nothing newly crashes — the problem is the comment asserts a safety net that doesn't exist. Three independent passes found this separately, each with its own repro.
  • Idle-drop exposure (ssh_pool.py:95-110). _is_alive returns transport.is_active() — a local flag, no I/O — and _default_factory never calls set_keepalive. Pre-PR a connection lived for one operation; now one lives for the whole multi-day run. A half-open connection behind a NAT still reports active, gets handed out, and check_connections' exec_command('ls') blocks until TCP retransmit timeout — which, single-threaded, stalls all jobs on all servers. Also: upload_file/download_file are the only SSHClient methods without @check_connections. Suggest transport.set_keepalive(30) and adding client._sftp.get_channel().active to _is_alive. (needs a live cluster to observe — 7/10)
  • Pool teardown is CLI-only (ARC.py:64-70). reset_default_pool() fires only in main(). Every in-process consumer — T3 driving ARC across RMG iterations, notebooks, functional/functional_test.py:106,123, functional/restart_test.py:54,151,178,201 — leaks the pool across successive ARC objects. check_servers_known_hosts() went into ARC.__init__, which suggests the object is the right seam: try/finally in ARC.execute().
  • The dummy-fixture fallback is closed on one path only. arc/imports.py:69-71 (untouched) still does try: import settings / except ImportError: pass, silently leaving ARC on server1.host.edu. The new preflight guards only inside the container, only on the arc subcommand branch — the interactive shell and *) pass-through bypass it, as does every host-side run. Compounding: check_servers_known_hosts skips addresses ending in PLACEHOLDER_ADDRESS_SUFFIX = '.host.edu', so the new startup check is silent on precisely the placeholder case. Raising from arc/imports.py when ~/.arc/settings.py exists but fails to import would make the container guard redundant rather than load-bearing.
  • The preflight checks settings.py but not submit.py (arc_preflight.py:135-157). arc/imports.py:86-97 swallows that ImportError the same way, falling back to the repo's dummy submit templates. This is a worse silent failure than the dummy-server case: jobs run and produce numbers, with the wrong partition/modules/scratch dir. Same for inputs.py. The compose file's own comment makes the argument ("a remote run needs this mount as much as it needs the SSH material").
  • The pipe's remote-server guard fails open (pipe_coordinator.py:106-116). The guard resolves first_server with next((s for s in server_list if s in servers_dict), None), skipping entries absent from settings['servers']; real job resolution takes ess_settings[adapter][0] unconditionally. With ess_settings['gaussian'] = ['zeus', 'local'] and 'zeus' missing from servers, the guard resolves 'local' and permits the pipe while every real job resolves 'zeus' — the exact deadlock the guard exists to prevent. It also returns without refusing when first_server is None, is case-sensitive where check_ess_settings is case-insensitive, and ignores the args['trsh']['server'] override that adapters/common.py honours. Sharing one resolution function between guard and adapters/common.py would fix all four.
  • remove_remote_files / remove_dir added with no callers (adapter.py:512-520, ssh.py:544-555). Only callers are tests. An unwired remote rm -rf tends to get connected later without re-deriving its interaction with the download_file change above. Wire it or drop it from this PR.
  • Docker security posture notes (reasoning only — see coverage caveat below): docker-compose.yml:77's ${ARC_KNOWN_HOSTS:-/dev/null} means the default container run has an empty host-key store — the /dev/null default is well-reasoned (Docker would otherwise create a root-owned dir at a missing source), the comment just doesn't state the resulting posture. The forwarded agent (:20,37,61) can sign anything with every loaded identity against any host for the container's lifetime; worth documenting the blast radius and suggesting ssh-add -c. ${ARC_IMAGE:-laxzal/arc:latest} is a mutable tag in a personal namespace — consider a digest pin. entrywrapper.sh:230's chown -R mambauser:mambauser /work contradicts the rule the PR's own new is_bind_mount() helper states 190 lines above (pre-existing, but this PR is what makes /work default to ${ARC_WORKDIR:-.}). entrywrapper.sh:147,177: id_has_running_process runs as PID 1 before anything else starts, so may_share_id approves every non-system collision — harmless today, not the guard it reads as. On entrywrapper.sh:64: the socket-widening chmod is gated behind ARC_WIDEN_AGENT_SOCKET=1 and loudly warned, which is the right call; one accuracy nit is that the exposure is bounded by the agent socket's 0700 parent dir.
  • Repo style (CLAUDE.md). adapter.py:245,276,284 — function-local imports; no circularity exists (python -c 'import arc.job.ssh_pool, arc.job.adapter' succeeds). adapter.py:261 — no return annotation, no Returns: section. adapter.py:316,449 — no return annotation. adapter.py:477_upload_with_ssh(self, ssh) unannotated.

Checked and found sound — so nobody re-treads these
  • Dockerfile:203's chown after USER mambauser does not break the build — a same-owner chown by the owning non-root user is permitted (probed, exit 0).
  • The narrowed except leaks no new exceptions. SSHException covers AuthenticationException/BadHostKeyException/PasswordRequiredException; OSError covers gaierror/NoValidConnectionsError/FileNotFoundError. Only KeyboardInterrupt/SystemExit newly escape — the intended improvement.
  • A key: pointing at known_hosts still authenticates: paramiko's _auth catches per key class, then falls through to the agent and look_for_keys. Same identity, still works. (Worth a release note anyway — this PR removes the 'key': '.../known_hosts' line from the commented pharos example at base settings.py:31, so that population exists.)
  • Pool races aren't reachable today. grep -rn "threading\|multiprocessing\|concurrent.futures\|asyncio\|Thread(" over non-test arc/ returns only ssh_pool.py's own docstring; pipe mode parallelises via subprocess.Popen in separate processes. Design gaps for a future concurrent scheduler, not present defects.
  • Credentials can't cross between pooled servers — the pool keys on server name, and the only runtime write into servers anywhere in arc/ is servers[server]['memory'] = 64 at trsh.py:927.
  • Stale pooled connections are reaped (paramiko sets _transport = None on close, so _is_alive is False). No channel exhaustion from check_connections' unclosed exec_command('ls')Channel._handle_close removes it from Transport._channels.
  • Loading known_hosts doesn't break key-type negotiation — paramiko client.py:229-232 only reorders sec_opts.key_types.
  • orca_neb's abs_path can't be None (adapters/common.py:268 calls set_file_paths() before :278 calls set_files()).
  • borrow() lacking try/finally around its yield is correct — the pool deliberately retains the client, and ssh_pool_test.py:122-126 covers propagation.
  • No silent fallback to local execution: should_use_pipeFalse propagates to PipePlanner._try_pipe_job → empty set → legacy_queue_execution, which for server != 'local' still goes over SSH.
  • Restart/resume unaffected; restart.yml semantics unchanged. No second transport exists (no subprocess ssh/scp/rsync, no bare Transport, no ControlMaster in the submit templates) — the "no existing pooling" half of the reuse check stands.
  • The rebase is clean: d033cbbf is the tip of main. The three nearest upstream commits touching these files were checked anyway — no overlapping hunk.

Where this review is weak, so you can weight it accordingly: the Docker half has zero executed coverage. test_docker_smoke.py gives 18 failed / 2 passed / 6 skipped on a bare host (micromamba: command not found), so entrywrapper.sh (+232 lines, runs as entrypoint with the mounted keys and forwarded agent) and arc_preflight.py (181 new lines) were reasoned about, not run — settling those needs a real docker build plus a container run. The idle-drop finding needs a live cluster. Also unreconciled: the PR reports 249 tests passed, I measured 275 passed + 15 subtests in the same stated scope — probably a collection-scope difference, but worth a glance. Separately and not caused by this PR: adapter_test.py is order-dependent under -n auto (2 failures, reproduced identically on d033cbbf), so parallel CI runs of that file are unreliable.

Happy to open a PR against your branch for the NEB path + its test if that's useful.

@calvinp0
calvinp0 force-pushed the fix_remote_job_lifecycle branch from fa404f1 to e3f5078 Compare August 20, 2026 09:41
Comment thread arc/job/adapter_test.py Fixed
Comment thread arc/job/adapter_test.py Fixed
Comment thread arc/job/adapter_test.py Fixed
@calvinp0

Copy link
Copy Markdown
Member Author

Reviewed this at fa404f125 against d033cbbf with an agent fleet (8 passes, incl. mutation testing and a cross-model pass). Everything below was executed unless marked otherwise — repros in a read-only worktree, no jobs submitted, no connections opened.

First, the credit: this PR genuinely fixes host-key handling. Base fed the private key path to load_system_host_keys(), which yields an empty host-key store under AutoAddPolicy — ARC had zero host verification, ever. That's a real fix and it was invisible until you touched it. The pooling is correctly built, and 4 of the 5 behaviors I mutation-tested are properly guarded (dropping key_filename → 4 failures; swapping policies → 2; disabling dead-connection eviction → 2; inverting the pipe refusal → 9). The findings below are about the edges, not the direction.

The one thing worth fixing first

orca_neb.py:229-231 writes a relative path into a template Orca can't resolve. This is the PR's only chemistry-affecting hunk, and it has zero test coverage.

adapter.py:442 builds remote_path from servers[server].get('path', '') — when 'path' is absent the join collapses to a relative path. 'path' is absent from all three shipped remote server examples in settings.py and is documented only under local:

server1    remote_path='runs/ARC_Projects/proj/spc/job1'                     isabs=False
with_path  remote_path='/storage/group_name/u/runs/ARC_Projects/proj/spc/job1' isabs=True

The shipped Orca Slurm template then runs the job somewhere else entirely:

WorkDir=/state/partition1/user/{un}/$SLURM_JOB_NAME-$SLURM_JOB_ID
cd $WorkDir
cp $SubmitDir/input.in .          # only input.in is copied

So NEB_END_XYZFILE "runs/ARC_Projects/.../product.xyz" resolves from /state/partition1/..., Orca aborts, every NEB TS guess for that reaction fails, and ARC falls through to other TS methods saying nothing about why. Secondary: .lower() on line 441 silently rewrites 'path': '/Storage/Users'/storage/users.

Suggested fix: put bare filenames in the NEB template and add reactant.xyz/product.xyz to the cp $SubmitDir/... line. The files are already uploaded into the remote path, so nothing needs rendering.

And it's unguarded — I reverted abs_path to always use self.local_path (pre-PR behavior) and re-ran: 2 passed, 0 failed. orca_neb_test.py's only server= is 'local'. Worth adding a non-local server case asserting abs_path == self.remote_path, written against the corrected shape.

One root cause behind four findings

connect()'s while times_tried < 1440 loop catches Exception and sleeps 60s, turning every permanent, deterministic failure into a 24-hour silent stall. Four separate symptoms are this one defect:

1. **`strict_host_key_checking: True` doesn't refuse — it stalls for 24h.** `RejectPolicy` raises a plain `SSHException`, which `_connect` catches and retries, then the outer loop swallows:
   ```
   SSHException:            outer loop result=exhausted, _connect calls=5
   AuthenticationException: outer loop result=exhausted, _connect calls=5
   ```
   
   
       
         
       
   
         
       
   
       
     
   Both the `_connect` docstring ("refusing any host that is not already in known_hosts") and `docs/source/remote_submission.rst` ("The connection is refused") describe behavior the code doesn't have.

2. **Wrong key or username → 2880 auth attempts over 24h** against a login node. `AuthenticationException` is an `SSHException` subclass. Not a regression (the base bare `except` had this too), but narrowing the except was the moment to fix it.

3. **A host-key _mismatch_ is newly reachable.** Base never loaded real `known_hosts`, so `BadHostKeyException` was structurally impossible; now it lands in the same loop. Note `get_servers_missing_host_keys` (`ssh.py:642`) tests `lookup(address) is None` — it sees _missing_ keys and cannot see a _mismatch_, so the startup check gives false comfort for exactly the case that now hard-fails. Concretely: a login alias round-robining to `login1`/`login2` with distinct keys, or any rekeyed cluster. _(read, not executed — 8/10)_

4. **A missing `key` path also stalls 24h.** The shipped `'key': 'path_to_rsa_key'` literal triggers it, and `arc_preflight.check_key` classifies it non-fatal. _Not a regression_ — base `ssh.py:373` raised `IOError` on the same input into the same loop. But `ssh.py:387`'s new docstring claims passing the key as `key_filename` "avoids paramiko raising on a configured key path that does not exist on this machine" — it doesn't; only an _unset_ key does.

One fix covers all four: classify the exception once — re-raise AuthenticationException, BadHostKeyException, RejectPolicy rejections, and missing-key OSErrors; retry only transport/banner failures.

Two more with executed repros

download_file silently reversed its contract (ssh.py:198-206). Base always called sftp.get, and paramiko 4.0.0 opens localpath "wb" before fetching — so a missing remote file left a zero-byte local file. The base comment said so explicitly ("if the remote path does not exist an empty file will be created at the local path… to avoid screwing up ESS trsh"). The new retry loop returns before that:

BASE  d033cbbf: local err.txt after a MISSING remote file -> b''                        => cleared
HEAD  fa404f125: local err.txt after a MISSING remote file -> b'slurmstepd: error: *** JOB 12345 CANCELLED DUE TO TIME LIMIT ***'
                                                                                       => STALE CONTENT SURVIVES

adapter.py:892-908 rebuilds additional_job_info from whatever out.txt/err.txt are on disk, and determine_job_status scans that string for server-error keywords. A latched stale err.txt can mark a healthy job time-limit-errored, or mask a new failure with an old one. This gets sharper once remove_remote_files is wired up. Either restore truncate-on-miss, or delete the local target before returning early.

ARC suppresses its own host-key warning (ssh.py:428 + common.py:217 + main.py:317). WarningPolicy.missing_host_key() emits via warnings.warn(), not logging. initialize_log() installs warnings.filterwarnings(action='ignore', module='.*paramiko.*') two lines before check_servers_known_hosts(), long before any connection:

--- BEFORE ARC's filter ---   warnings captured: 1 ['Unknown ssh-rsa host key for cluster.exa']
--- AFTER  ARC's filter ---   warnings captured: 0 []

On an unknown host ARC connects and prints nothing — not to stdout, not to arc.log. The _connect docstring's justification ("logs the unknown key… the warning repeats on every connection") is false on every clause. A small MissingHostKeyPolicy subclass calling logger.warning() with the fingerprint would land the user-visible half of the improvement.
Ten more findings (click to expand) — pool coverage, dead fallback, silent-import fallbacks, pipe guard, style

Checked and found sound — so nobody re-treads these

Where this review is weak, so you can weight it accordingly: the Docker half has zero executed coverage. test_docker_smoke.py gives 18 failed / 2 passed / 6 skipped on a bare host (micromamba: command not found), so entrywrapper.sh (+232 lines, runs as entrypoint with the mounted keys and forwarded agent) and arc_preflight.py (181 new lines) were reasoned about, not run — settling those needs a real docker build plus a container run. The idle-drop finding needs a live cluster. Also unreconciled: the PR reports 249 tests passed, I measured 275 passed + 15 subtests in the same stated scope — probably a collection-scope difference, but worth a glance. Separately and not caused by this PR: adapter_test.py is order-dependent under -n auto (2 failures, reproduced identically on d033cbbf), so parallel CI runs of that file are unreliable.

Happy to open a PR against your branch for the NEB path + its test if that's useful.

download_file no longer leaves a stale local file behind: a missing remote file now empties the local path, exactly reproducing what main did (paramiko's get opens the local path "wb" before discovering the miss), and the miss is logged at warning level.

Orca NEB refuses a non-absolute remote path rather than emitting a deck Orca will fail on.

Permanent auth/host-key failures are classified and raised on the first attempt instead of feeding the 24-hour retry.

The per-connection host-key report now goes through ARC's logger, since initialize_log filters paramiko's warnings.warn. The pool fallback is rebuilt on ExitStack so it covers the lease and not the caller's body.

Regarding the .lower() comment - it is gone Ithis moves files for anyone whose configured path has uppercase) and a server with no path is reported once per run. I deliberately did not invent an absolute default :~ is quoted by shlex.quote and is not expanded by SFTP, so it would create a directory literall named ~, and resolving the real home needs a connection ARC does not have at job-construction time.

Also, in the docs remote_submission.rst:140-144 states the 24-hour consequence verbatim. And the _connect dosctring clause read as wrong is about key=None. adapter.py:363-365 is identical on main, so the defect is pre-exisiting. Fixed here now.

@calvinp0
calvinp0 force-pushed the fix_remote_job_lifecycle branch from e3f5078 to 68cb19a Compare August 20, 2026 10:57
Comment thread arc/job/ssh_pool_test.py Fixed
@calvinp0
calvinp0 force-pushed the fix_remote_job_lifecycle branch from 3e69c3f to fd7c938 Compare August 25, 2026 08:06
calvinp0 added a commit that referenced this pull request Aug 25, 2026
…regardless of compute_thermo (#1022)

## What this fixes

An Arkane **kinetics** input generated by ARC can reference species it
never declares, so Arkane dies before computing anything:

```
File "arkane/input.py", line 297, in reaction
    reactants = sorted([species_dict[spec] for spec in reactants])
KeyError: '[O]C=O[1]'
```

This was found on a run where everything else went right: two species
and a transition state all optimised and frequency-checked on a cluster,
and then **no rate coefficient at all**.

## Cause

`ArkaneAdapter.render_arkane_input_template` builds `species_list` under
`if e0_only or spc.compute_thermo:`. A caller that sets
`compute_thermo=False` — as an orchestrator does when it wants kinetics
but does not want a full thermodynamics job queued — gets:

- the **E0** render (`e0_only=True`) → gate passes → all species
declared;
- the **kinetics** render (`e0_only=False`) → gate fails → *none*
declared, while `reaction(...)` still names them by label.

`generate_species_files` writes the per-species statmech files either
way, so ARC writes `statmech/kinetics/species/<label>.py` to disk and
then writes a main input that references that species without declaring
it. `compute_thermo` should govern whether a thermo job runs — not
whether a species may be declared in an input that names it.

The existing `test_generate_arkane_input` never caught this because
`ARCSpecies.compute_thermo` defaults to `not is_ts`, i.e. `True` for
ordinary species; the defect only surfaces when a caller sets it to
`False` explicitly.

## The change

Collect the labels named by any reaction in the render's reaction list,
and let a species through the gate if it is one of them:

```python
if e0_only or spc.compute_thermo or spc.label in reaction_species_labels:
```

Deliberately narrow rather than dropping the gate: dropping it would
push species into thermo inputs that exclude them on purpose. For a
thermo render `self.reactions` is `None`, so the new set is empty and
the gate is unchanged — thermo inputs are unaffected.

## Testing

- New unit test: a reactant with `compute_thermo=False` now renders as a
`species('R', ...)` line in a kinetics input.
- Mutation: restoring the old gate turns it red, with the rendered text
showing `reaction(reactants=['R'], products=['P'])` above zero
`species(` lines.
- `arc/statmech/`: 50 passed.
- **End to end on real converged output.** Taking the failing run's own
kinetics input and adding only the two `species(...)` declarations this
fix emits, Arkane exits 0 and writes the rate coefficient it previously
could not produce:
  ```
  kinetics(label = '[O]C=O(1) <=> O=[C]O(8)',
kinetics = Arrhenius(A=(36.3562,'s^-1'), n=3.33173,
Ea=(61.8336,'kJ/mol'),
                           T0=(1,'K'), Tmin=(300,'K'), Tmax=(3000,'K')))
  ```
No new quantum chemistry — the converged logs were already on disk.
(Those numbers come from an RMG-Py checkout that is behind `main` in
`arkane/`, so treat them as a plumbing result, not a physical one.)

Note for anyone running these tests: `arc/statmech/` needs `-n0`. Under
the default xdist config `test_generate_arkane_input` flakes, because
sibling workers share the on-disk
`arc/testing/arkane_input_tests_delete` directory. Pre-existing and
unrelated to this change.

---

## Separately: an unrelated bug found on the same run

`arc/job/ssh.py:42-43` unpacked `self.connect()`, which returns `None`.
Not fixed here, and no longer needs to be: @calvinp0 pointed out it is
already fixed in #1001 (`self._sftp, self._ssh = self.connect()` →
`self.connect()`, with a regression test). Tracked there.
@calvinp0
calvinp0 force-pushed the fix_remote_job_lifecycle branch from fd7c938 to db2a20a Compare August 27, 2026 21:22
@alongd

alongd commented Aug 28, 2026

Copy link
Copy Markdown
Member

Re-verified all 18 findings against the current head db2a20a40 (the branch was rebased since I reviewed fa404f125, so nothing was taken on trust). 16 of 18 are fixed, and each fix is guarded by a test that fails when I revert it — I mutation-checked the NEB path, the PERMANENT_CONNECTION_ERRORS classification, download_file's truncate-on-miss, the host-key policy, the ExitStack fallback, and the execute() teardown. The pooling now covers the hot path: 5 ticks × 10 labels × 2 remote servers = 100 borrows → 2 connections, with exactly +1 reopen on a dead transport. Thank you — the resolve_job_server() extraction and the arc/imports.py overlay reporting are both better than what I suggested.

One thing has to change before this merges, and it's a consequence of the NEB fix rather than something the review anticipated.


orca_neb's new ValueError aborts the run, where the old bug lost one TS guess

_get_abs_path() (orca_neb.py:259-277) raises inside the constructor, and nothing on the path catches it:

_get_abs_path()  →  raises ValueError
  ← set_files()                     (orca_neb.py:279)
  ← _initialize_adapter             (adapters/common.py:275)
  ← job_factory
  ← Scheduler.run_job               (scheduler.py:1053)
  ← Scheduler.spawn_ts_jobs         (scheduler.py:1873)  ← bare call, no try

Executed against this head:

CONSTRUCTOR RAISED: Cannot write an Orca NEB input file for server "no_path": the remote path is
'runs/ARC_Projects/test_orca_neb/i-C3H7__=__n-C3H7_TS/tsg_a1', which is not absolut...

orca_neb is in the default ts_adapters (settings.py:119) and in ts_adapters_by_rmg_family for many families, and 'path' is absent from all three shipped remote server examples — it is documented only under local and appears commented-out in the configs I can see. So the common configuration goes from "one TS guess writes a deck Orca fails on" to "the whole ARC run dies at TS spawn." That inverts the severity of the thing the fix was for.

PR #996 doesn't cover this either — it is not in official/main, and it catches DependencyError only.

Either shape works, your call: raise something spawn_ts_jobs already tolerates, or widen the except at the spawn site so the refusal costs one TS method. A test asserting the run survives a path-less remote server would pin it.

The refusal itself is the right design — relying on $SubmitDir being visible from the compute node is true on any shared-home Slurm cluster, and it avoids touching submit.py. It just needs to fail soft.


Two one-liners worth taking while you're in these files

A missing key path still stalls 24 h. paramiko's _auth catches only SSHException around _key_from_filepath, so a non-existent key_filename raises FileNotFoundError → an OSError → not in PERMANENT_CONNECTION_ERRORS. Executed at this head with connection_attempts=5:

1. _key_from_filepath -> FileNotFoundError ... is SSHException? False | is OSError? True
2. connect() with 5 attempts: _connect calls=5 sleeps=4  => RETRIED (not classified permanent)
3. client.key = 'path_to_rsa_key'

At the default 1440 that's the original 24-hour stall, and the trigger still ships — settings.py:55,62,70 all carry the 'key': 'path_to_rsa_key' literal, and arc_preflight.py:73-92 classifies it non-fatal (and only inside the container). The docstring half is correctly fixed at ssh.py:583-587. Suggest classifying a missing-key OSError as permanent.

The keepalive is lost on the first reconnect. _default_factory sets transport.set_keepalive(30) (ssh_pool.py:60-87), but SSHClient.connect() (ssh.py:503-548) does not, and check_connections reconnects the same client object in place. Executed: after the factory sets the keepalive on transport 0, a half-open socket drives check_connectionsconnect() → transport 1, on which set_keepalive is never called. The pooled client silently loses idle-drop protection for the rest of the run — which is exactly the multi-day scenario pooling introduces. One line at the end of connect(); nothing tests it today.


Verified fixed — so nobody re-treads these
  • NEB path + .lower() + coverage. .lower() gone from adapter.py:439, path used verbatim; test_a_configured_path_is_used_verbatim guards it. Reverting _get_abs_path to self.local_path → 2 failed; dropping only the isabs guard → 1 failed.
  • connect() classification (3 of 4). PERMANENT_CONNECTION_ERRORS (ssh.py:138-141) + raise on first attempt (:532-536, :638-639). RejectUnknownHostKeyPolicy raising its own UnknownHostKeyError is what makes the strict-mode refusal distinguishable — I confirmed paramiko client.py:456-458 calls the policy unguarded, so it really escapes. _host_key_mismatch_error names both fingerprints; get_servers_with_conflicting_host_keys adds the offline-checkable half and the old function now states its own limitation. Mutating the tuple to (RuntimeError,) → 4 failures.
  • download_file. A pre-seeded stale err.txt comes back b'' with sftp.get never called. 7 tests at ssh_test.py:774-853; no-op'ing _empty_local_file fails 2.
  • Host-key warning. With warnings.filterwarnings(action='ignore', module='.*paramiko.*') installed exactly as common.py:217 does: 0 warnings captured, 1 arc WARNING record with hostname and SHA256: fingerprint. Reverting to warnings.warn fails 3.
  • Pool hot path. scheduler.py:3562, trsh.py:1484/1489/1503/1525 (the :1488 leak is now a with), cfour.py:304, main.py:854 all pooled. Remaining non-test SSHClient(: 4, of which 2 are the pool itself and delete_check_files_on_servers is deliberately unpooled with a rationale.
  • ExitStack fallback is now reachable. Both arms executed: a factory raising ServerError, and a pool whose borrow raises RuntimeError → one-shot client, closed on exit. A ValueError from the body still propagates untouched. Stripping it fails 5.
  • Teardown. main.py:561-576 try/finally, ARC.py no longer duplicates the seam; 3 tests cover finish/raise/interrupt.
  • Overlay reporting. _report_unusable_overlay on settings (:115), submit (:132/136/140), inputs (:156), deduped and queued so it survives initialize_log. Executed with three broken overlays: all three warn, all three reach arc.log.
  • Pipe guard, all four sub-defects. Executed against resolve_job_server(): the ['zeus','local']-with-zeus-absent fail-open case now refuses; None refuses; casefolding matches check_ess_settings; the trsh override is honoured both directions.
  • remove_dir quoting (Copilot's thread). rm -rf -- {shlex.quote(...)}; a path containing ' ; rm -rf ~ round-trips as one token. Eight quoted sites in ssh.py; ssh_test.py:532-592 asserts both.

Suites ssh_test, ssh_pool_test, adapter_test, imports_test, main_test, trsh_test, scheduler_test, cfour_test, pipe_coordinator_test, common_test, orca_neb_test: 268 passed, stable across 3 runs.

Not fixed, and fine by me
  • All five Docker posture notes. docker-compose.yml, entrywrapper.sh and arc_preflight.py are byte-identical to what I reviewed. These were reasoning-only findings with no executed coverage on my side, and they're documentation/hardening rather than defects — I'm not holding the PR for them, but the ${ARC_KNOWN_HOSTS:-/dev/null} default posture and the forwarded-agent blast radius are each worth one sentence in docker.rst eventually.
  • remove_remote_files / remove_dir still have no callers. The docstrings now say so outright, which removes the trap for the next reader. Staged rather than forgotten — good enough.
  • Style residue in ssh_pool.py__init__/_close_quietly without docstrings, missing Args:/Returns: at :109,143,175,183,199, double-quoted strings and %s logging where ARC uses f-strings, and multi-line docstrings opening on the """ line. The adapter.py sites you touched are all clean now, and borrow_ssh_client is in ARC style — the file is just internally inconsistent. Cosmetic; fold it in whenever.
Drive-by, pre-existing on main

trsh.py has logger.denug(...) in the unknown-cluster-software branch of trsh_job_on_server (blames to 7ef79c8bc) — an AttributeError waiting on any non-OGE/Slurm server. Sitting in a file this PR edits; free to fix here or separately.


Fix the NEB abort and I'll approve. CI is green on all nine checks and the branch is mergeable — this is the only thing standing between it and main.

Every remote job opened its own paramiko Transport for upload, submission and
status polling. A TS search issuing ~100 guess optimizations against one server
opened ~100 connections, which is slow and trips per-user connection limits on
some clusters.

Add a process-global pool (arc/job/ssh_pool.py). A remote queue job leases one
client for the duration of its submission and reuses it for both file upload
and run; _open_or_borrow_ssh() prefers that leased client and otherwise borrows
from the pool. The borrower never closes a client it does not own.

The third case, a one-shot client for when the pool itself cannot lease one, is
real rather than nominal. Written as `try: return pool.borrow(server)` with an
`except` around it, it could never fire: borrow is a @contextmanager, so calling
it merely builds the generator, and the factory runs when the caller enters the
with-block -- outside the try meant to catch it. Rebuilt around
contextlib.ExitStack, the fallback covers exactly the lease, i.e. entering the
pool's context manager, and nothing else. In particular it does not cover the
caller's own work: a job that raises inside its with-block still raises, instead
of having its failure read as a broken pool and its body re-entered against a
fresh client.

Two pre-existing defects in the remote path that set_file_paths() builds are
fixed here as well, since a remote job's files are only reachable if that path
is. The server's configured 'path' was lowercased before use, which silently
rewrites any path with an uppercase component -- remote file systems are
case-sensitive, and /Home/Users is not /home/users. It is now used verbatim.
NOTE that this changes where files land for anyone whose configured 'path'
contains uppercase: their earlier runs are under the lowercased tree, and ARC
will now use the path as configured.

A server with no 'path' still gets a path relative to the SSH login directory,
because there is no absolute path ARC can know offline -- the home directory is
the server's to report, and set_file_paths() runs at job construction with no
connection open. Rooting it at '~' would be worse rather than better:
_send_command_to_server quotes the remote path with shlex.quote, so the remote
shell would take the tilde literally, and SFTP performs no tilde expansion at
all, so both would end up creating a directory actually named '~'. Relative,
which the remote shell and SFTP both resolve against the login directory, stays
correct. What changes is that it is no longer silent: such a server is reported
once per run, naming the setting, because an adapter that has to name a path
inside an input file cannot work with it.

Pooled clients also need closing when ARC exits. ssh_pool.py documented that
ARC.py's main() calls reset_default_pool(), but nothing did -- every caller was
a test, so pooled SSHClients were left to interpreter shutdown rather than
closed. Releasing them belongs to the run rather than to the command-line
entry point, so ARC.execute() does it in a finally: connections are torn down
on ctrl-C and on an exception as well as on a clean run, and a consumer that
drives ARC in process -- a library caller, a test, a pipe worker -- releases
them too, which an ARC.py-only hook could never do. ARC.py is unchanged.

The borrow itself is now one function, ssh_pool.borrow_ssh_client(), rather
than a method on JobAdapter. The pool's other callers are not adapters --
Scheduler.get_server_job_ids(), server troubleshooting, the ESS survey -- and
each would otherwise have grown its own copy of the lease-then-fall-back
dance. _open_or_borrow_ssh() keeps only what is adapter-specific, the
per-execute() leased client, and delegates the rest; the shared-client branch
is contextlib.nullcontext rather than a hand-rolled generator.

A pooled connection is held for the whole run and sits idle between polls, so
_default_factory sets a keepalive on the transport. An SSH daemon's
ClientAliveInterval or a firewall's idle timeout otherwise drops it silently:
the socket stays half-open, Transport.is_active() keeps reporting True, and the
pool's liveness check therefore hands out a handle whose first command hangs
until TCP gives up.

upload_file() and download_file() were the only SSHClient methods reaching for
self._sftp without @check_connections. That was harmless while every caller
opened its own client and used it immediately; with a client that has been
alive for hours it is not, since a dead transport surfaces as the transfer
failing rather than as a reconnect. Both are decorated now.

The pool is tested directly rather than only through a JobAdapter, so its own
contract -- reuse, reaping a dead client, retaining ownership on context exit,
idempotent close_all -- is stated by its tests instead of implied by adapter
behaviour. arc/job/ssh_pool_test.py drives SSHConnectionPool with a stub
factory and covers the cases adapter_test.py could not reach, namely that a
raising with-body leaves the pool reusable and that reset_default_pool() closes
pooled clients rather than just dropping the reference. The adapter-driven
integration tests stay with the adapter, which is what they actually exercise.

Two claims the pool's docstrings make were still untested, and the imports for
them were sitting unused in adapter_test.py: that a remote-queue execute() with
no pool injected borrows from the instance get_default_pool() returns, and that
reset_default_pool() -- ARC.py's exit hook -- closes the clients those jobs
opened and leaves a usable empty pool behind. Both are now asserted rather than
implied. The pool tearDowns also called set_default_pool(None), which drops the
reference without closing anything, so each test class leaked its stub clients
and contradicted the lifecycle ssh_pool.py documents; they call
reset_default_pool() instead.

Also two test-only cleanups CodeQL flags: two factory lambdas that only forwarded
their argument now pass the callable itself, and the "a raising with-body leaves
the pool usable" test uses assertRaises' callable form. Its context-manager form
made every statement after the block unreachable to a control-flow analyser,
because nothing in the CFG says assertRaises.__exit__ suppresses the exception.

Two of CodeQL's remaining alerts on this file are in adapter_test.py and are
the same two defects already fixed in ssh_pool_test.py. The assertRaises
context-manager form around a with-block whose last statement is `raise` makes
everything after the block unreachable to a control-flow analyser, since
nothing in the CFG says __exit__ suppresses the exception; both tests use the
callable form, and still assert what they did -- that the caller's own
exception reaches the caller, and that the pool is usable afterwards. And the
module both imported arc.job.adapter and imported names from it; the
module-alias form existed for two patch.object() calls, which are now
patch('arc.job.adapter.<name>'), so the file uses one import form. Dropping the
alias also removes a name that a class attribute in the same file shadowed.

Absorbed from PR #1000 by @alongd, brought in here rather than merged so the two
pull requests do not conflict over the same lines: set_file_paths() splits the
project's remote directory out of the job's remote path as remote_project_path,
which is what the remote check file cleanup is scoped to.

Record the keepalive interval on the client as well as on its transport. A keepalive belongs to
a paramiko Transport, and check_connections reconnects a client in place when its socket has
gone half-open, so the transport the factory set the keepalive on is not the one the client ends
up holding. SSHClient.connect() re-applies the recorded interval to every transport it opens,
which is the case a connection held for a whole run actually meets.

Also brings the module to ARC's conventions: docstrings on __init__ and _close_quietly, Args and
Returns sections, f-string logging in place of %s, single-quoted strings, and no comments on
code lines.
@calvinp0
calvinp0 force-pushed the fix_remote_job_lifecycle branch 2 times, most recently from 6a06d1a to d2e5962 Compare August 28, 2026 19:06
…scoped retry, and an absolute remote path

Makes the SSH key optional so that an ssh-agent or the default key paths can authenticate,
verifies host keys rather than adding them silently, and scopes the connect retry to the
failures retrying can actually resolve.

Classify a key file that does not exist as a permanent failure. paramiko guards only
_key_from_filepath's SSHException, so an absent key_filename surfaces as FileNotFoundError,
which is an OSError and so was not in PERMANENT_CONNECTION_ERRORS. At the default 1440
connection attempts that stalled the run for 24 hours over a path that will never appear, and
the shipped server examples all carried the 'key': 'path_to_rsa_key' placeholder that triggers
it. It is told apart from a transient network OSError by being a FileNotFoundError naming the
configured key path, so a refused or reset connection is still retried.

Re-apply a recorded keepalive interval to every transport connect() opens, so that a pooled
client reconnected in place by check_connections does not lose idle-drop protection for the
rest of the run.

Report a server with no absolute remote path before any job is spawned. An adapter whose input
file names a path on the server cannot run on a server that has no 'path' entry: its remote
directories are then relative to the SSH login directory, which the deck cannot name, so the
job cannot be built at all. orca_neb is such an adapter and is in the default ts_adapters, so
this was reached only when the first reaction got to its TS search, part way into a run.
check_ess_settings() already validates the adapter and server names it is given and runs before
any calculation is spawned, so it now also takes the run's ts_adapters and checks the servers
each path-naming adapter will run on, naming the server and the setting to fix. The check is
keyed on the ESS settings entry an adapter resolves its server from rather than on the adapter's
own name, because OrcaNEBAdapter is given its server while it is still an OrcaAdapter and
therefore runs wherever orca runs.

The shipped remote server examples gain the absolute 'path' entry they omitted, which is what
made that the default configuration rather than an unusual one.
The pool was built and tested but the highest-frequency caller never used it.
Scheduler.get_server_job_ids() opens a connection per server per poll cycle,
for every cycle of every job's lifetime -- on a run of any length that is the
dominant source of connections by an order of magnitude, and it is exactly the
traffic shape a per-user connection limit is there to stop. It borrows now, so
a run's polling costs one connection per server rather than one per poll.

The same for the three sites in trsh_job_on_server() and for CFour's
execute_queue(). CFour overrides execute_queue() rather than calling
JobAdapter.legacy_queue_execution(), so it did not inherit the sharing the
other adapters got; it goes through _open_or_borrow_ssh(), which means its
submission also reuses the client its upload just used.

One of those trsh sites leaked. `ssh = SSHClient(server)` with no `with` and no
close() left a connection open for the rest of the process every time a job was
troubleshooted by changing node. It never actually reached the server, since
check_connections() raised TypeError on an unconnected client (fixed with the
rest of the SSH work), but the leak is real for any caller that got past it.

Not routed: delete_all_arc_jobs() in arc/job/ssh.py. Its only caller is
arc/utils/delete.py, a standalone command-line utility that deletes jobs and
exits, outside any ARC run; it opens no connection ARC would otherwise reuse
and its `with` already closes what it opens, so pooling would swap a closed
connection for one left open until the interpreter exits. ssh.py is also the
module ssh_pool.py imports, so pooling there would have to be a
function-local import to avoid a cycle -- a cost with nothing bought.

Absorbed from PR #1000 by @alongd, brought in here rather than merged so the two
pull requests do not conflict over the same lines: the scheduler records the
remote project path of each server it spawns a job on, which is what the end of
a run hands to the check file cleanup.

Fix logger.denug in the unknown-cluster-software branch of trsh_job_on_server, which raised
AttributeError instead of declining to troubleshoot.
arc/imports.py caught ImportError from the local settings.py, submit.py and
inputs.py overlays and passed. An overlay that fails to load therefore leaves
ARC running on the repository defaults with nothing said, and the defaults are
a working configuration, so there is no other symptom: the cluster templates in
submit.py or the server definitions in settings.py are simply not the ones the
user wrote, and a run goes to the wrong place, or to ARC's dummy servers, for
hours. The usual cause is an overlay that imports something not installed in
the environment ARC is running in, which is easy to produce and invisible once
produced.

Report it instead. Control flow is unchanged -- the defaults still stand, the
run still starts -- and the message names the file and the error, and is queued
so it survives the log being initialized later in the run.

Loudness follows what actually failed, since the two cases mean opposite
things. A file that loaded but does not define the name is a partial overlay,
which is the ordinary way to override one setting and leave the rest alone: a
submit.py that defines submit_scripts and neither incore_commands nor
pipe_submit is correct, and warning about it would put two lines in every run's
log of every user who has one. That is a debug line. A file that did not load
at all loses every setting in it, and is a warning, reported once per file
rather than once per name imported from it. The two are told apart by whether
the module is in sys.modules after the failure.

A syntax error in an overlay is not covered, and cannot be: it is a
SyntaxError, not an ImportError, and it propagates out of arc/imports.py and
stops ARC from starting -- loudly, if confusingly, but never silently.
PipeRun.submit_to_scheduler() invokes qsub/sbatch on the machine running ARC,
and the worker (python -m arc.scripts.pipe_worker) reads pipe_root from its
local filesystem. When the engine's resolved server is remote, that submission
errors silently and the run deadlocks waiting for results that can never
arrive.

Make should_use_pipe() refuse a non-local server so the planner falls back to
per-job queue submission over SSH, and say in the log which engine and server
triggered the refusal and what is being used instead -- that fallback is slower
than a pipe run, so without the message the only symptom is an unexplained
slowdown.

Supporting pipe on a remote server needs it rebuilt around batch jobs staged on
the remote side, which is out of scope here.

The guard resolved its server with `next((s for s in server_list if s in
servers_dict), None)`, which fails open in three ways: it skips an entry that
names an unconfigured server and silently judges the next one instead, it
compares server names case-sensitively when a server name is a settings key
whose casing the user chose, and it permits the pipe when nothing resolves at
all. That last one matters most, because derive_cluster_software() applies the
same "skip what is not configured" rule and then falls back to guessing slurm,
so an unresolvable server produced a pipe submitted with a guessed template.

Resolve the first entry unconditionally, compare case-insensitively, and refuse
unless the result is a configured server that is this machine. Refusing costs
the run only the bundling -- the planner submits the tasks as individual queue
jobs, which works for a local and a remote server alike -- so failing closed
here is cheap and failing open is not.

"Cannot be resolved" is not the same as "has no server", and conflating the two
would have disabled TSG pipe mode outright. A TS-guess batch carries
engine=<method>, and gcn, kinbot, xtb_gsm and the rest are not ESS: they are
absent from ess_settings by design and run in this process, which is why
_initialize_adapter resolves a server only for an engine ess_settings names and
leaves every other one with server=None, and why set_file_paths gives such a
job no remote path at all. In process is this machine, so those tasks pipe. The
refusal is for an engine that ess_settings does name and that still does not
resolve to a configured local server -- an ESS declared and available nowhere,
or named on a server that is not configured.

The resolution itself is not a second implementation. _initialize_adapter()
already decided which server a job goes to, inline: a trsh override first, then
the first entry of the ESS settings for the adapter, with a bare string read as
a single server. That is now resolve_job_server() in arc/job/adapters/common.py,
the module that owns the concept, called by both, so the pipe's answer is the
answer the job would have got rather than a lookalike. Extracting it also fixes
an IndexError on an empty server list, and drops a redundant re-check of a
condition the enclosing `if` had already established.

Absorbed from PR #1000 by @alongd, brought in here rather than merged so the two
pull requests do not conflict over the same lines: _initialize_adapter()
initializes the new remote_project_path attribute.
The NEB input template embeds absolute paths to reactant.xyz and product.xyz,
built from self.local_path. For a remote server that names a directory on the
machine running ARC, which does not exist on the cluster, so Orca cannot open
the geometries.

set_files() already uploads both files, and they land in remote_path. Choose
the path accordingly -- remote_path for a remote server, local_path otherwise
-- mirroring the choice JobAdapter already makes for the pipe payload's "pwd".
This needs no change to any submit script, since the files are staged where the
input now points.

remote_path is only absolute when the server carries a 'path' in the settings:
JobAdapter builds it as os.path.join(servers[server].get('path', '').lower(),
'runs', 'ARC_Projects', ...), which is relative when 'path' is unset, and the
shipped Orca submit scripts cd into a scratch directory before running. Refuse
to write the deck in that case, naming the server and the setting, rather than
emitting one Orca will fail on for a reason that is not visible in the input.
The relative path is pre-existing behaviour of arc/job/adapter.py shared by every
adapter. The commit adding the connection pool fixes what can be fixed there --
the lowercasing of a configured path, and a report naming any server without one
-- but a server with no 'path' configured has no absolute remote path that ARC
can know before it connects, so refusing here is the other half of that fix.

The adapter's tests only ever ran with server='local', where reverting the path
choice to local_path still passed. They now cover a remote server too: the deck
must point into the absolute remote_path, and a relative remote_path must be
refused.

Raise SettingsError rather than ValueError when the remote path is not absolute, matching the
error arc.common.check_remote_paths_of_path_naming_adapters raises for the same condition at
startup, so the two read as one settings problem. Validation now reports this before any job is
spawned for every server the adapter is configured to run on; the refusal here remains the
backstop for the paths validation does not see, such as a job moved to another server by
troubleshooting.
ARC inside the image can now reach a remote HPC cluster either through a
forwarded SSH agent socket (preferred -- keys never enter the container, and
passphrase-protected keys keep working) or through a read-only bind mount of the
user's key material, with the ~/.arc settings overlay mounted alongside it.

entrywrapper.sh:
- pass SSH_AUTH_SOCK explicitly across the `runuser -u mambauser` privilege drop,
  and explicitly `env -u` it when the socket turned out to be unusable, so the SSH
  client falls back to key files instead of failing against a dead socket;
- never chown/chmod a bind-mounted /home/mambauser/.ssh (detected by comparing the
  device of the path with its parent), so read-only mounts neither fail nor leak
  ownership changes back to the host. paramiko does not enforce 0600 on key files,
  so a read-only mount is fine;
- emit actionable PUID/PGID diagnostics when a mounted socket or .ssh directory is
  not accessible to the container user.

PUID/PGID remap: the base image carried a vestigial 'ubuntu' account at 1000:1000
and the entrypoint refused to remap onto any occupied ID, so `-e PUID=$(id -u) -e
PGID=$(id -g)` -- the very flags needed for bind-mount ownership -- aborted with
exit 1 for the majority of Linux desktop users, and docker-compose.yml defaults
both to 1000, so the compose path was broken by default. The Dockerfile deletes
that unused account in the final stage, and the entrypoint no longer depends on
the image being fixed: a collision with an ordinary, idle account is resolved by
sharing the ID (usermod -o), since permissions are numeric and nothing then has to
be deleted or renamed. "Unused" is established from /proc rather than assumed from
the account's name. A collision with the superuser, with a system account (<= 999),
or with an account owning running processes is still fatal, exit code unchanged,
but now names the exact flag to drop and states what ownership the mounts would
fall back to.

Agent socket: prepare_ssh_agent_socket() ran `chmod o+rw` on the forwarded socket
whenever the container user could not open it. A bind mount shares the inode, so
that mutated the user's live agent socket on the host -- 600 -> 606, verified --
leaving it readable and writable by every local user for as long as the agent runs,
never restored, and contradicting the rule this same file applies to every other
bind-mounted path. Restoring it is impossible here in any case, since the
entrypoint hands off with exec and no EXIT trap can fire. The remap above is the
mechanism instead: a container user carrying the host UID opens a 0600 socket with
nothing changed. The widening survives only behind an explicit
ARC_WIDEN_AGENT_SOCKET=1, and when used it reports what it changed on the host and
how to undo it; without the opt-in, an inaccessible socket is reported with the
PUID/PGID fix and agent forwarding is skipped.

Dockerfile: add openssh-client to the final stage (ssh-keyscan and friends for
debugging; ARC itself uses paramiko) and pre-create /home/mambauser/.ssh so a bind
mount lands with sane ownership. Final stage only.

docker-compose.yml: replace the stale definition (foreign image, non-existent
/home/rmguser/KMClass path, a CONTAINER_MODE variable the entrypoint never read)
with one matching the real entrypoint contract: /work bind mount, ~/.arc, agent
socket forwarding, PUID/PGID.

arc_preflight.py: arc/imports.py catches ImportError from the local settings.py
and passes, so a settings.py that cannot be imported leaves ARC running against
its *dummy* servers with no output at all -- verified: a settings.py whose first
line imports a missing module yields settings['servers']['server1']['address'] ==
'server1.host.edu', silently. In a container a mistyped mount path lands in exactly
that state, and the only symptom is a run that spends hours failing to reach a host
that never existed. The pre-flight imports the overlay exactly as ARC does -- as a
top-level module on sys.path, without importing ARC itself -- and exits 78
(EX_CONFIG) if it is present but unimportable; a directory with no settings.py is
reported as absent and exits 0. Everything else is a warning, deliberately: a
missing or unreadable `key` file, a server with no `key` and neither an agent nor
a default key, and strict_host_key_checking with no known_hosts. A server that is
configured but unused in a given run must not be able to abort it.
ARC_SKIP_PREFLIGHT=1 bypasses the whole check. The check runs for `arc` only, never
for `rmg`, and the overlay directory is passed as argv so the entrypoint and the
tests cannot drift apart on the path.

The overlay is mounted read-only, with PYTHONDONTWRITEBYTECODE=1 so Python does not
pointlessly attempt __pycache__ writes into it; runuser sets HOME=/home/mambauser,
hence the mount target.

test_docker_smoke.py: SSH-oriented smoke checks that need no remote server
(paramiko importable and constructible, arc.job.ssh imports, openssh-client
present, entrypoint agent-socket handling, forwarded socket usable when present,
the PUID remap and the socket mode -- each of the last three confirmed to fail
against the pre-fix entrypoint), plus an opt-in live-cluster test skipped unless
ARC_SMOKE_SSH_HOST is set. The overlay is exercised through a subprocess, never by
importing arc in the pytest process, since some ARC branches disable the ~/.arc
overlay whenever pytest is loaded, which would silently hollow out an in-process
test.

Not changed: usermod -u recursively chowns the home directory, which was raised as
a possible startup cost now that ~/.julia is 2.9 GB. Measured on the built image --
28,795 files, 0.375 s without the remap against 0.925 s with it. That does not
warrant working around usermod, so it is left alone.

Julia is pinned to 1.10.11 rather than tracking the 1.10 channel. juliacall
segfaults on import under 1.10.12 and the channel floats to the newest patch, so
an unchanged Dockerfile silently changed what it installed; RMG-Py pinned the
same version for the same reason in 62eb728c0.

docker-compose.yml also mounts known_hosts read-only at
/home/mambauser/.ssh/known_hosts, paramiko's only host-key location, so the
container gets the same treatment as the key material and the ~/.arc overlay and
so ARC's startup host-key check means something there. The source is
${ARC_KNOWN_HOSTS:-/dev/null} rather than ${HOME}/.ssh/known_hosts directly:
Docker materialises a missing bind-mount source as a directory, so naming that
path unconditionally would leave a root-owned directory at
$HOME/.ssh/known_hosts on any host that has never written the file, which then
breaks ssh on the host itself. /dev/null always exists and reads as an empty key
list.
The container and SSH halves of remote submission were in place; what was missing
was the user-facing configuration around them. These docs are written against the
`key` and host-key semantics this branch introduces, not against main's.

docs/source/remote_submission.rst (new, in the toctree): authentication via a
forwarded agent (preferred -- keys never enter the container and
passphrase-protected keys keep working) or via a mounted key file; host key
verification and the new per-server strict_host_key_checking, including why an
unseeded known_hosts matters in a fresh container now that WarningPolicy and
RejectPolicy have replaced the silent AutoAdd; the ~/.arc overlay mount, which a
remote run needs as much as the SSH material since submit.py carries the cluster's
PBS/Slurm templates; both `docker run` invocations and the compose equivalent; and
the entrypoint's exit codes.

Two limitations are documented rather than worked around:
- ARC never builds a paramiko.SSHConfig, so ~/.ssh/config is not read at all and
  ProxyJump/bastion hosts are unsupported. This is true on bare metal too, and is
  called out so nobody blames the container for it.
- a default-bridge container reaches an ordinary login node with no extra flags,
  since paramiko speaks SSH itself; the exceptions are a host VPN whose routing
  excludes docker0, and internal names served only by a VPN-pushed resolver.

Each claim is checked against the code rather than assumed: paramiko's
load_system_host_keys() reads ~/.ssh/known_hosts and nothing else, so
/etc/ssh/ssh_known_hosts is not mentioned as an alternative (seeding it under
strict_host_key_checking would have refused every connection); a rejected host key
raises into the same 24-hour retry loop and so presents as a hang rather than a
fast failure; the retry reason reaches the logger only on every tenth attempt,
the others going to stdout; and Docker materialises a missing bind-mount source as
a root-owned directory, which is what a stale SSH_AUTH_SOCK or an absent ~/.arc
produces on the host.

installation.rst and running.rst described `key` as a private key path, which was
wrong on main and is right as of this branch; they now say so, present the agent
route as the default, and mention strict_host_key_checking. docker.rst gains the
remote-submission pointer, index.rst the toctree entry, and the stray `key` in the
advanced.rst node-limits example is dropped, since that example is about cpus and
memory.

remote_submission.rst now also states why warning rather than rejecting is the
default host-key policy -- a refused key does not fail a long-running scheduler
once but starves it while the driver stays alive, and it presents as a hang
rather than an error -- and how to opt into refusal per server. The Startup
Checks section leads with the check ARC itself performs on every run, in a
container or not, and describes the compose file's known_hosts mount and why
ARC_KNOWN_HOSTS defaults to /dev/null. running.rst gains a pointer to the same
startup report.

Absorbed from PR #1000 by @alongd, brought in here rather than merged so the two
pull requests do not conflict over the same lines: keep_checks is documented as
covering the servers a project ran on, not only the local project directory.

Document the server 'path' key and give every remote server example one. No example defined it,
so the documented remote configuration was the one in which an input file that must name a path
on the server, such as Orca NEB's, cannot be written.
arc/job/adapters/gaussian_test.py and arc/job/adapters/common_test.py both built their
adapter fixtures under arc/testing/test_GaussianAdapter and both deleted that directory in
tearDownClass. Under pytest-xdist the two modules run on different workers, so whichever
class finished first removed the tree the other was still writing input files into, and the
three tests that render an input file and read it back failed with FileNotFoundError on the
input.gjf they had just written. The same collision is possible within this module alone,
since the worksteal scheduler may split a class across workers and each worker runs its own
setUpClass and tearDownClass.

Create the project directory with tempfile.mkdtemp() in setUpClass and remove it through
addClassCleanup, so every class setup owns a directory no other class or worker can name,
and each removes only the directory it created. No test asserts on the directory's path;
they all derive it from the adapter's local_path.

Reproduced by running this module together with arc/job/adapters/common_test.py under
-n 4 --dist worksteal: 8 of 8 runs failed before, 7 of 7 pass after.
@calvinp0
calvinp0 force-pushed the fix_remote_job_lifecycle branch from d2e5962 to bf4a202 Compare August 28, 2026 19:19
@calvinp0

Copy link
Copy Markdown
Member Author

NEB abort — fixed, though not the way you suggested. Both options fail silently mid-run: the user loses a TS method and only finds out from a log line, and widening the except at the spawn site would swallow unrelated constructor failures too. So it's caught at startup instead. check_ess_settings already validates adapter and server names and raises SettingsError from main.py:398 before anything spawns, so the check went there — a server missing path gets reported once, naming the server and the key. _get_abs_path() keeps its refusal as a backstop, now raising SettingsError rather than ValueError so the two read as one settings problem.

One thing that isn't visible in the diff and shaped the design: OrcaNEBAdapter subclasses OrcaAdapter, and orca.py:205 sets job_adapter = 'orca' before _initialize_adapter at :213, which is where set_files() calls _get_abs_path(). A NEB job therefore resolves its server from ess_settings['orca'], so a check keyed on 'orca_neb' would never have matched a real config.

The root cause was the templates. No remote server block in the docs defines path — nine examples, zero occurrences. server1/2/3 in settings.py and all nine docs examples now set it. That fixes new configurations; the validation only covers ones already written, since ~/.arc/settings.py replaces the whole servers dict and never picks up template changes.

Where I might be wrong on this. If you think fixing the templates is enough, the validation is arguably insurance for a population neither of us can measure — I don't know how many existing overlays lack path, and mine doesn't. I kept it because a stale overlay still kills the run otherwise, which is the regression you flagged, but it's ~20 lines and one call site and I'd drop it if you disagree. Same question on the backstop: I made it a hard raise, since validation covers every server in ess_settings['orca'] and the ordinary path can't reach it. What's left is a trsh server override, direct in-process adapter use, and scale.py:103 calling check_ess_settings with one argument. If you think one of those should degrade instead, say which.

Missing key — a FileNotFoundError naming the configured key file is now classified permanent. Other OSErrors stay retryable; ConnectionRefusedError and a FileNotFoundError for a different file are both tested at 5 attempts / 4 sleeps.

Keepalive — the client records the interval it was given and re-applies it to each new transport, so check_connections reconnecting in place no longer drops it. I avoided importing ssh_pool from ssh; ssh_pool.set_keepalive stays the single definition.

Both pre-existing ones are fixed too. logger.denugdebug. And in the same function, the break was sitting at the for level rather than inside the if, so the loop always exited on the first line and the for...else never ran — a submit file with no node directive got uploaded unchanged, and one with a directive further down never had it replaced. Two tests now pin both branches. orca_neb_test.py also moved off ARC_TESTING_PATH onto tempfile.mkdtemp().

Two corrections. spawn_ts_jobs doesn't tolerate anything — there's no try on spawn_ts_jobsrun_jobjob_factory_initialize_adapter, and scheduler.py's eight except clauses are all elsewhere, so option one would have needed a new exception type and a new catch. And test_a_configured_path_is_used_verbatim doesn't exist; the guard is test_task_1b_a_remote_server_resolves_the_endpoint_files_on_the_server.

350 passed. Each fix reverted individually with the guarding test confirmed failing. Rebased onto current main.

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.

4 participants