Skip to content

fix: SSH-race and remote-resource-leak fixes around stopProject/runProject (aicshud/WHEEL#1020, #1021, #1022) - #140

Merged
so5 merged 7 commits into
RIKEN-RCCS:mainfrom
so5:fix/jobscheduler-cancel-marked-as-success
Sep 16, 2026
Merged

so5 merged 7 commits into
RIKEN-RCCS:mainfrom
so5:fix/jobscheduler-cancel-marked-as-success

Conversation

@so5

@so5 so5 commented Sep 16, 2026

Copy link
Copy Markdown
Collaborator

Summary

This PR fixes three related bugs around SSH connection and remote-resource lifecycle management during stopProject() / runProject(), found while investigating a customer-reported resume failure.

  • aicshud/WHEEL#1020: a race between stopProject()'s teardown and runDispatcher()'s own error-path teardown, both of which could call removeSsh() concurrently for the same project, tearing down an SSH connection that the other path still needed (or vice versa).
  • aicshud/WHEEL#1021: stopProject() was unconditionally clearing deferredCleanupRegistry entries on stop, discarding pending remote-resource cleanups (executers/transferrers) that had not actually run yet — leaking those remote resources.
  • aicshud/WHEEL#1022: consolidated the three cleanup calls that always belong together (removeSsh, removeExecuters, removeTransferrers) into a single releaseRuntimeResources() helper in core/projectController.js, and used it to close a gap where runDispatcher()'s fatal-error catch block called only removeSsh(), never removeExecuters()/removeTransferrers().

Commits

  • test: reproduce aicshud/WHEEL#1020 (red)
  • fix: stop runDispatcher()'s own removeSsh from racing stopProject() too (aicshud/WHEEL#1020)
  • fix: don't race stopProject()'s teardown with runProject()'s own (aicshud/WHEEL#1020)
  • test: reproduce aicshud/WHEEL#1021 (red)
  • fix(projectController): stop discarding pending deferred remote cleanups on stop
  • test: reproduce aicshud/WHEEL#1022 (red)
  • refactor(projectController): consolidate the removeSsh/removeExecuters/removeTransferrers cleanup set

Each fix follows a test-first (red → fix → green) pattern; the reproduction tests remain in the suite as regression coverage.

Testing

  • npm run testDocker -w server — full suite green (1658 passing).
  • 3x live stop/resume regression cycle on real Fugaku hardware to confirm no behavioral regression from these changes.

Related

Filed against aicshud/WHEEL#1020, #1021, #1022 on GitLab (issue tracker for this project). These will be closed manually with a link to this PR once merged, since GitLab and GitHub are separate platforms with no automatic cross-linking.

🤖 Generated with Claude Code

https://claude.ai/code/session_01C3jKNM1qubomM8UTRdkEWu

so5 and others added 7 commits September 16, 2026 09:43
Adds failing tests demonstrating that runProject() and stopProject()
independently call removeSsh()/removeExecuters()/removeTransferrers()
(and runProject() also runDeferredCleanups()), which race each other
when a project is stopped mid-run: Dispatcher.pause() resolves
start()'s promise (via a synchronous "stop" emit) well before its own,
still-in-flight nested job-task cancellation (pjdel etc., which needs
the project's SSH connections) actually completes, so runProject()'s
teardown can disconnect SSH out from under stopProject()'s still-
running cancellation - observed as "ssh instance is not registerd for
the project", which aborts onStopProject() before it ever reaches its
own explicit "stopped" state write.

- dispatcher.js: Dispatcher#start()'s onStop handler should set a new
  `stoppedExternally` flag before resolving, so callers can tell this
  settled via an external stop rather than natural completion.
- core/projectController.js: runProject() should skip its own
  removeSsh/removeExecuters/removeTransferrers/runDeferredCleanups
  when the dispatcher was stopped externally, deferring entirely to
  stopProject()'s own (already fully-awaited) teardown instead.

Confirmed red: 3 failing (server/scratchpad/jobmanager-repro-test-output.log).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C3jKNM1qubomM8UTRdkEWu
…shud/WHEEL#1020)

Dispatcher#start()'s onStop handler now sets `stoppedExternally = true`
before resolving, recording that this settlement came from an external
stop (pause()/remove(), as triggered by stopProject()) rather than the
dispatcher's own natural completion. pause() emits "stop" synchronously
before awaiting its own nested job-task cancellation (pjdel etc., which
needs the project's SSH connections), so start()'s promise - and
therefore runProject()'s await on it - can resolve well before that
cancellation actually finishes.

runProject() now skips its own removeSsh/removeExecuters/
removeTransferrers/runDeferredCleanups teardown when
rootDispatcher.stoppedExternally is set, instead deferring entirely to
stopProject()'s own teardown (which only runs those once its own await
on rootDispatcher.remove() - including all nested pause() calls - has
fully finished). Previously, runProject()'s teardown could win the
race and disconnect SSH out from under stopProject()'s still-running
nested cancellation, surfacing as "ssh instance is not registerd for
the project" and aborting onStopProject() before it ever reached its
own explicit "stopped" state write.

Full server test suite: 1656 passing, 0 failing, 15 pending, including
both new #1020 reproduction tests. Test output:
server/scratchpad/jobmanager-repro-test-output.log

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C3jKNM1qubomM8UTRdkEWu
…oo (aicshud/WHEEL#1020)

The previous #1020 commit (f2996dc7) only guarded core/projectController.js's
runProject() against redundantly tearing down SSH/executers/transferrers when
the dispatcher was stopped externally. It missed a third, independent call
site: handlers/projectController.js's runDispatcher() - the function that
actually calls runProject() - had its own unconditional removeSsh(projectRootDir)
in a shared `finally` block that runs on every exit path (success, external
stop, or error), completely bypassing runProject()'s stoppedExternally guard.
Since runProject() now returns almost immediately once "stop" is emitted,
this outer finally block's removeSsh could still fire while stopProject()'s
own, separate (concurrent, fire-and-forget) invocation was still mid-flight
cancelling nested job tasks over SSH - reproducing the exact same
"ssh instance is not registerd for the project" error the first commit was
meant to fix.

stopProject() only ever settles a Dispatcher's start() via the "stop" event
(never "error"), so runProject() never throws when a stop (user-initiated or
task-failure-triggered) is in progress - meaning this outer removeSsh call is
only ever needed as a safety net for the genuine, rare case where runProject()
itself threw before reaching its own cleanup (e.g. rootDispatcher.start()
rejecting via the dispatcher's "error" event). Moved it (and
removeAllJWTServerPassphrase) from the shared `finally` into the `catch`
block, where it can't race a concurrent stopProject() at all - runProject()
now unconditionally owns cleanup on every non-throwing exit.

Verified on Fugaku with debug instrumentation (temporary, removed before this
commit): confirmed removeSsh was called exactly once, from stopProject()'s own
(correct) call site, with no "ssh instance is not registerd" error, and the
project's persisted state ended up correctly as "stopped". Repeated 3
consecutive clean->run->stop cycles with no recurrence (this was a race
condition, hence the repeat).

Full server test suite: 1656 passing, 0 failing, 15 pending. Test output:
server/scratchpad/jobmanager-repro-test-output.log

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C3jKNM1qubomM8UTRdkEWu
Adds a failing test demonstrating that stopProject() discards pending
deferred remote cleanups (files preserved as remote-symlink targets
for a not-yet-executed downstream task) via clearDeferredCleanups().
This permanently leaks them: a resumed run never re-registers the
entry (finished components are skipped on restart), and cleanProject
only touches local git state, never the remote host - so once an
entry is cleared here, nothing ever deletes the corresponding remote
files.

Confirmed red: registering a deferred-cleanup entry, calling
stopProject(), then calling runDeferredCleanups() again finds nothing
to process (getSsh never called), because stopProject() already wiped
the registry.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C3jKNM1qubomM8UTRdkEWu
…ups on stop

stopProject() called clearDeferredCleanups(), which silently discards any
entries in the deferred-cleanup registry without deleting the remote files
they describe. Those entries exist precisely because a finished task's
output was delivered downstream via a remote symlink whose target must be
preserved for a not-yet-executed consumer; discarding the entry on stop
means that consumer's target file leaks on the remote host forever, since
no later resume-to-completion or cleanProject can ever re-register it.

Simply stop calling clearDeferredCleanups() in stopProject() and leave the
registry untouched, so a later natural completion (after 0+ resumes) can
still process it via runDeferredCleanups(). Running the deferred cleanup
immediately at stop time was considered and rejected: at stop time we
cannot safely tell whether the not-yet-executed downstream consumer has
already run, so deleting immediately risks removing a file it still needs.

Ref: aicshud/WHEEL#1021

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C3jKNM1qubomM8UTRdkEWu
runDispatcher()'s catch block (handlers/projectController.js) is the last
chance to release SSH connections/executers/transferrers when runProject()
throws before ever reaching its own cleanup (e.g. rootDispatcher.start()
itself rejecting, not a task failure). It only ever called removeSsh(),
never removeExecuters()/removeTransferrers() - leaking stale executer/
transferrer map entries that a subsequent run attempt could reuse.

Ref: aicshud/WHEEL#1022

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C3jKNM1qubomM8UTRdkEWu
…s/removeTransferrers cleanup set

Every path that tears down a project run's SSH connections, executers, and
transferrers (natural completion, an external stop, and a fatal dispatch
error) used to hand-roll its own subset of these three calls, spread
across core/projectController.js's stopProject()/runProject() and
handlers/projectController.js's runDispatcher(). This is how
runDispatcher()'s catch block came to only call removeSsh() and silently
skip removeExecuters()/removeTransferrers() (aicshud/WHEEL#1022) - a
genuine implementation gap, not an intentional omission, since both
functions are pure in-memory Map cleanups with no side effects that make
them unsafe to call unconditionally.

Introduce releaseRuntimeResources(projectRootDir) in
core/projectController.js as the single shared implementation of this
3-point cleanup set, and call it from all three sites:
- stopProject()
- runProject()'s guarded tail (only on natural completion, unchanged from
  aicshud/WHEEL#1020)
- runDispatcher()'s catch block, now also releasing executers/transferrers

This is a pure consolidation of already-reviewed cleanup logic (no new
runtime behavior beyond closing the #1022 gap above) - deferred cleanups
(runDeferredCleanups) and JWT passphrase cleanup are deliberately left
out of this shared function, since they are not called uniformly from all
three sites and have their own separate semantics.

Ref: aicshud/WHEEL#1022

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C3jKNM1qubomM8UTRdkEWu
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant