Skip to content

[staging CI] unslothai/unsloth#8170 - #221

Closed
danielhanchen wants to merge 62 commits into
mainfrom
pr-8170-ci
Closed

danielhanchen wants to merge 62 commits into
mainfrom
pr-8170-ci

Conversation

@danielhanchen

Copy link
Copy Markdown
Collaborator

Disposable CI run for unslothai/unsloth#8170. Do not merge; closed after CI.

danielhanchen and others added 8 commits August 8, 2026 11:51
A Windows user could not update Studio until they killed a stray python by hand:
the tool sandbox runs its payload under a shell wrapper, the kill path reaped
only the wrapper, and `unsloth studio update` then refused to run because a
process still held the managed environment.

- taskkill /T on Windows, so a tool payload cannot outlive its wrapper
- a console-close handler, since CTRL_CLOSE_EVENT never becomes a Python signal
  and the graceful shutdown was skipped entirely when the window was closed
- the desktop updater drains the app job before standing down crash cleanup, and
  re-arms it when the install never happens
- children are recorded on disk and swept at the next startup, which is the only
  reaper macOS has after a crash or a force quit
- the job status is logged instead of failing silently

Also fixes a liveness probe that used os.kill(pid, 0); on Windows that is
TerminateProcess, so it killed the process it was asking about.
…d per owner, drop the job drain

- the console handler ran _signal_handler on the thread Windows creates for the
  event, where signal.signal raises, so closing the window did no cleanup at all
- bound that work to the ~5s Windows allows before it kills the process
- one record file per owner pid: two Studios can share a home, and a single file
  let the second erase the first's children
- add a Windows process identity (creation time) and refuse to signal a pid that
  cannot be verified
- drop the whole-job drain: it would also terminate the WebView2 hosts, and
  cleanup_child_processes already taskkills the backend tree
A record that is not an object, or whose children are not dicts, raised out of
the startup sweep and would have stopped Studio from starting. Pair the Linux
start time with the command name as well, since start time alone has 10ms
granularity.
Ctrl+C on Windows raised UnboundLocalError inside the console callback, where
the BOOL result is then undefined, so the event could be reported as handled and
Studio would not stop. The updater no longer re-arms kill-on-close before
relaunching, which would have made the old process kill the replacement it just
started, and it resets the exit-cleanup guard so a retry after a failed
installer still reaps the backend. The RAG embedder and cloudflared are recorded
like the other sidecars, a llama-server that survived a failed kill stays
recorded, the Windows kill path checks the captured creation time before
taskkill, and record writes are serialised.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c6a8562842

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +393 to +395
// Deliberately NOT re-arming kill-on-close before the restart: relaunch()
// starts the replacement as a child, so it inherits this job, and re-arming
// would make this process kill it on the way out.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Re-arm cleanup when the relaunch marker fails

When the app was launched with --hidden, mark_in_app_relaunch can reject because its config directory or marker cannot be written. This call is outside the relaunch try, after the updater's on_before_exit hook has already consumed the termination guard and disabled Windows kill-on-close, so the outer recovery path restarts the backend without invoking resume_desktop_update_cleanup. The app then continues with no crash reaper, and a later force-exit can orphan that backend (while an update retry also skips its pre-installer cleanup); include marker/import failures in the same re-arm path.

Useful? React with 👍 / 👎.

danielhanchen and others added 2 commits August 8, 2026 14:44
The delayed Windows kill skips a captured pid it cannot verify, since the job
object still takes the tree at exit. An owner whose identity cannot be read
counts as live rather than gone, so a momentary ps failure no longer costs a
running Studio its sidecars, and that lookup pins TZ so a timezone change does
not read as a different process. A child that outlived terminate_all keeps its
record instead of losing the only handle on it, with zombies told apart from
survivors. The whole relaunch handoff is inside the recovery scope, so any path
that leaves this process running re-arms cleanup.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6c1efba31b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +669 to +670
for path in sorted(directory.glob("*.json")):
killed.extend(_reap_one_record(path, timeout))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Repeat the orphan sweep after killing recorded owners

On macOS, nested processes create dependent records: the backend records a multiprocessing worker, while that worker records its llama-server. If the worker's filename sorts before the backend's (for example across a PID digit boundary or after PID wrap), this single pass first skips the worker record because its owner is still alive, then kills the worker while processing the backend record, and never revisits the skipped record. Because macOS has no parent-death signal, the llama-server can remain alive until another Studio launch; sweep until no newly-dead owners are found rather than relying on record ordering.

Useful? React with 👍 / 👎.

Comment on lines +384 to +387
} catch (installError) {
// Failed or cancelled: we keep running, so the cleanup the pre-exit hook
// stood down has to come back.
await invoke("resume_desktop_update_cleanup").catch(() => {});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Do not discard failure to re-arm Windows cleanup

On Windows, if resume_desktop_update_cleanup rejects because restoring the job limit fails, this handler suppresses that result and the outer recovery path starts the backend anyway. The app then continues with kill-on-close disabled, so a later force-exit can orphan the restarted backend and an update retry can again reach the installer with live environment users. The current revision's fresh evidence is that the new native resume command explicitly returns these job-object errors, but both frontend recovery call sites discard them; keep the backend stopped or surface a fatal recovery state when re-arming fails.

Useful? React with 👍 / 👎.

terminate_all now applies the same test the startup sweep does: a pid whose
identity cannot be read is left alone and kept in the record for the next launch
to retry, rather than signalled on the chance it is still ours. The startup
reaper re-checks liveness before dropping a record, so a kill that did not take
stays reapable, and the breadcrumb unlink happens under the record lock so a
concurrent adopt cannot have its record deleted from under it.
Linux startup probes prctl with the read-only PR_GET_PDEATHSIG before reporting
the parent-death signal as in force, so a seccomp or container policy that
blocks it is reported as such rather than as a guarantee nothing keeps. The
backstop sweep takes its snapshot under the lock the writes already hold.
pre-commit-ci Bot and others added 3 commits August 8, 2026 15:35
…ts leader

The DiffusionGemma runner and sd-cli were spawned with lifetime kwargs that
are empty on macOS and never recorded, so nothing could reap them. Both now
adopt at spawn.

The Windows tool capture also revalidated a pid that no longer exists once the
wrapper exits, which is the case it was added for; each tool tree gets its own
job object instead, with the pid path as the fallback. The startup breadcrumb
sweep takes the tree too, not just the leader.

Probe PR_SET_PDEATHSIG itself, since seccomp can filter prctl per operation,
and stop the post-update backend restart when kill-on-close cannot be re-armed.
danielhanchen and others added 2 commits August 8, 2026 17:11
The runner shares Studio's process group, so the startup sweep could only
signal the runner itself and its visual server kept the GPU. start_new_session
makes it a group leader, which is what _posix_terminate needs to killpg.

Skip and Restart is offered on every error, so gating only the recovery path
still let a user start a backend while kill-on-close was disarmed. The flag
moved to a ref both paths check.
A recorded leader can exit first and leave its group running, and the
sweep then skipped the entry and deleted the record. The child's own
process group is recorded at adopt time (only when it leads one, never
Studio's) and signalled when the leader is gone. The group id is the dead
leader's pid, which the kernel holds while any task still references it
as a group, so it cannot belong to anyone else.

comm is mutable, so a worker calling prctl(PR_SET_NAME) or setproctitle
read as a recycled pid and was dropped unsignalled. Identity is the start
time alone; records written with starttime:comm still compare equal.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 35026b0d73

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +884 to +887
timeout = 15,
creationflags = getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
return

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Check taskkill status before consuming the record

On Windows, if the recorded leader exits after _reap_one_record verifies it but before taskkill runs, taskkill reports failure while subprocess.run still returns normally because check defaults to false (Python documentation). This unconditional return skips the fallback; the later liveness check sees the leader gone, leaves unresolved false, and deletes the only record of descendants that /T could no longer reach, permanently orphaning them. Inspect returncode and preserve the record or run the fallback when the command fails.

Useful? React with 👍 / 👎.

forget_pid dropped the recorded group as soon as its leader was reaped, so
a shim exiting before its visual server took the only handle on that group
with it. The record is kept while the group still has members, and the
backstop then reaps it.

The Windows backstop now takes the tree, matching the startup sweep, and
the identity probe prototypes CloseHandle like every other handle-width
call in this module.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f870e13bd8

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +1109 to +1112
if !crate::windows_job::kill_on_close_armed().unwrap_or(false) {
crate::windows_job::resume_after_update_installer().map_err(|error| {
format!("Refusing to start the backend with crash cleanup disarmed: {error}")
})?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Reset the cleanup guard when native start re-arms

Fresh evidence in this revision is that the native start guard re-enables only the Windows job limit, not the termination guard consumed by the updater hook. If a webview reload or tray start reaches this path after the job was suspended, the backend starts successfully, but TERMINATION_CLEANUP remains true; on the next update, cleanup_child_processes therefore skips the live backend before suspend_for_update_installer disables kill-on-close, allowing the installer to run while the backend still holds the environment open. Reset the termination guard together with the job limit here.

Useful? React with 👍 / 👎.

# spawned it. Only those deferred records are revisited, so nothing is
# signalled twice.
pending = sorted(directory.glob("*.json"))
for _pass in range(4):

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Sweep deferred owner records to a fixed point

Fresh evidence in this revision is the new retry loop, but its hard four-pass limit still leaves descendants behind when there are five or more dependent owner records ordered child-before-owner. Each pass can kill only the next owner in that chain, so the remaining live owner's record is still deferred when the loop ends and its sidecars survive until another Studio launch. Continue while a pass kills an owner and deferred records remain rather than imposing an arbitrary depth limit.

Useful? React with 👍 / 👎.

pre-commit-ci Bot and others added 2 commits August 9, 2026 07:45
…med record

Re-enabling the Windows job on the spawn path left TERMINATION_CLEANUP set,
so the next update attempt read cleanup as armed, skipped the resume, and its
pre-exit hook suspended kill-on-close without stopping the backend first.

An identity read back from a record can be any JSON value; reaching split()
with a number raised through the whole startup sweep, so one bad file left
every other orphan running. Non-strings now read as unverifiable, which keeps
the pid unsignalled rather than trusted.

A group whose members exited on the SIGTERM keeps answering killpg(pgid, 0)
where pid 1 does not reap, so the reap now rechecks membership instead of
waiting out the grace period.
pre-commit-ci Bot and others added 2 commits August 9, 2026 08:13
The leader-only fallback runs when the job object was unavailable, which is
exactly when the record is the only handle on those workers. Both callers
read the dead leader as the tree being gone and dropped it, so anything that
survived became unreachable. The tree kill now reports whether it took, and a
failure keeps the pid tracked and its record on disk for the next launch.
pre-commit-ci Bot and others added 2 commits August 9, 2026 10:27
The installer announced its validation server as stopped as soon as the group
leader exited, which drops the record while a child that ignored the SIGTERM
is still holding the GPU. It now waits for the group to empty, escalates to
SIGKILL, and only announces the stop once nothing is left.

An installer timeout killed the installer alone and left the announced server
for a sweep that never runs while this process lives; those children are now
terminated with it.

The diffusion group id is kept from the spawn, so a shim that exited before
the kill path (a failed health check, a crash before a reload) no longer
leaves its visual server with nothing able to reach it.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d9b125e349

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +913 to +920
try:
if _is_windows():
_windows_terminate_tree(pid)
else:
_posix_terminate(pid, timeout)
except Exception: # noqa: BLE001 - best effort, like the rest of this
pass
forget_pid(pid) # keeps the record if its group is still up

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve timed-out validation children when taskkill fails

On Windows, when the installer watchdog calls terminate_pid for an announced validation server and taskkill /T /F fails, _windows_terminate_tree returns false specifically because the tree may still be running, but this helper ignores that result and unconditionally forgets the PID. Fresh evidence beyond the earlier reaper-path finding is the new installer-timeout caller in update_flow.py, which uses this helper while the backend remains alive; the surviving validation tree therefore loses its breadcrumb and can keep holding the GPU or staged files through retries. Retain the record unless tree termination is confirmed.

Useful? React with 👍 / 👎.

danielhanchen and others added 7 commits August 9, 2026 11:12
# Conflicts:
#	studio/src-tauri/src/main.rs
The timeout path took them; a nonzero exit or a stream that ended mid-line
left them running. This process stays up after an update failure, and its own
live record shields those pids from a sweep that would not run anyway, so the
cleanup now happens in the finally that covers every way out.
terminate_pid falls back to the recorded process group when the leader has
already exited, keeps the record when a Windows tree kill could not be
confirmed, drains the announced children under a lock so the watchdog and the
reader thread cannot race, and the installer arms the parent-death signal on
the validation server it puts in a session of its own.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 35cef75347

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +917 to 922
if _is_windows():
# False is "only the leader was signalled": nothing else names those
# workers, so the record has to outlive this call.
tree_stands = not _windows_terminate_tree(pid)
else:
_posix_terminate(pid, timeout)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Refuse to terminate a PID whose identity has changed

When an announced validation server exits before the installer timeout/error cleanup drains it, its PID can be recycled before terminate_pid runs. This path reads only the recorded PGID and invokes _windows_terminate_tree or _posix_terminate without comparing the current process identity to _tracked_pids, so it can terminate an unrelated process (and, on Windows, that process's descendants). Verify the stored identity immediately before signaling, as terminate_all and _reap_one_record already do.

Useful? React with 👍 / 👎.

Comment on lines +652 to +656
if identity is not None or not _pid_alive(pid):
return identity
identity = _pid_identity(pid)
if identity is not None:
_tracked_pids[pid] = identity

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Do not attach a missing identity after PID reuse

If a short-lived child exits before adopt_pid can capture its identity, it remains tracked with identity=None; a later breadcrumb rewrite after that PID has been reused makes _refreshed_identity capture the unrelated process's identity. Subsequent cleanup then regards that identity as verified and signals the unrelated process. Preserve the unverifiable entry as such, or record that the original PID was already dead rather than filling its identity on a later write.

Useful? React with 👍 / 👎.

…dentity before a single-pid kill

The desktop stops this backend by signalling its process group and force-kills
it five seconds later, so a runner in a session of its own survives a backend
that is slow to shut down and keeps the GPU until the next launch sweeps it.
Put it back in that group, as the component installer already is, and reach the
visual server by walking the runner's children instead of killpg. The cached
group id goes with it: a pid is reusable once nothing holds the number as a
process group any more, so an id kept past its group eventually names a stranger.

terminate_pid signalled on the pid alone. An announced validation server can
exit without the line that clears it, so run the same identity test terminate_all
does before either termination branch.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3b294cda84

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +672 to +673
if identity is None or current is None:
return True

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Refuse to signal descendants without a verified identity

When identity collection fails transiently (for example, a macOS ps call times out), this treats an unverifiable PID as the original descendant. If that descendant exits and its PID is reused during the subsequent grace period, the final _still_the_same check also returns true and terminate_descendants sends SIGKILL to the unrelated replacement process. Preserve the fail-closed behavior used by terminate_all and terminate_pid: do not signal when either the recorded or current identity is unavailable.

Useful? React with 👍 / 👎.

The server was started in a session of its own everywhere, but only Linux can
pair that with a parent-death signal. On macOS it left the group Studio
force-kills while the only record of it is the announcement the backend has yet
to read, so a kill in that window orphaned it with nothing able to find it.

It stays in the inherited group there, and the kill path only reaches for killpg
when the server actually leads a group, so a shared group is never signalled.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7a2f09a747

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Only where the parent-death signal can be armed with it, so the two always
agree: the kill path may only killpg a group the server actually leads.
"""
return os.name == "posix" and sys.platform.startswith("linux")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Check PDEATHSIG before detaching the validation server

On Linux under a seccomp or container policy that rejects PR_SET_PDEATHSIG, this still enables start_new_session; _arm_parent_death ignores the failing prctl return value, so the server has neither the death signal nor membership in the backend's process group. If the installer is killed during validation—particularly before its child announcement is consumed—the validation server can keep holding GPU resources and staged files indefinitely. Probe the capability before detaching, or make the pre-exec hook exit when arming the signal fails.

Useful? React with 👍 / 👎.

@danielhanchen

Copy link
Copy Markdown
Collaborator Author

CI replication finished.

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