Skip to content

Rework GUI backend on Zig/MerJS and fix native regressions - #84

Open
pranavp311 wants to merge 67 commits into
justrach:release/0.0.177from
pranavp311:backend-zig-merjs-regression-fixes
Open

Rework GUI backend on Zig/MerJS and fix native regressions#84
pranavp311 wants to merge 67 commits into
justrach:release/0.0.177from
pranavp311:backend-zig-merjs-regression-fixes

Conversation

@pranavp311

@pranavp311 pranavp311 commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

Summary

This PR continues the GUI/backend migration work by replacing the old Tauri/Rust backend path with the Zig + MerJS native shell backend and fixes regressions found while dogfooding the native GUI.

Important

Merge/release blocker: MerJS is not yet available as a normal dependency.

The GUI native shell currently depends on a local MerJS checkout via gui/build.zig.zon:

.merjs = .{
    .path = "../../../merjs",
},

That means this PR builds on the author machine because a sibling merjs checkout exists, but a fresh Codegraff checkout will not build the native GUI unless the reviewer also has the same local MerJS checkout.

Before this PR can be considered merge/release-ready, we should do one of:

  1. Preferred: merge/publish the corresponding MerJS PR, then update gui/build.zig.zon to a pinned .url + .hash dependency.
  2. Fallback: vendor MerJS into this repo. Full vendoring is possible but noisy: the current MerJS repo has roughly 490 tracked files / 21MB. A smaller minimal vendored subset may be possible but would need careful validation.
  3. Temporary alternative: add MerJS as a git submodule, if maintainers are comfortable with that workflow.

Until one of the above happens, treat this PR as dependent on the unmerged MerJS work.

Backend + native shell rework

  • Adds a MerJS-powered native GUI build/package path under gui/build.zig and gui/native/main.zig.
  • Routes GUI API calls through a Zig backend runtime (gui/src-backend/runtime.zig) and MerJS route table (gui/routes.zig, gui/api/stub.zig).
  • Removes the old gui/src-tauri backend from the GUI runtime path.
  • Wires native shell actions through Mer where possible, including directory/file open flows, clipboard bridge commands, and app state persistence.
  • Reconciles release updater UI as a native-safe stub until native desktop updates are wired; CLI updates remain available via graff update.
  • Removes stale Tauri release-script references and updates GUI packaging flow around the native app bundle.

GUI/session regressions fixed

  • Restores chat sending and queued prompt behavior.
  • Restores chat navigation and persisted chat/session loading.
  • Adds session import/write support using harness-compatible session JSON files.
  • Restores paste/file-drop handling in the prompt composer.
  • Restores prompt-history attachment navigation, including Ultra prompt attachment-block parsing.
  • Wires model catalog/provider settings into the Zig backend.
  • Ranks model picker entries by usage and adds the UltraCode slash-choice picker.
  • Shares Codex CLI auth / provider login detection with the GUI provider settings.
  • Keeps draft/queued input usable while a request is running.
  • Fixes segmented button border overlap/z-index behavior.
  • Fixes sidebar footer/logo/settings overlap when many non-project chats are open and aligns the settings-view Codegraff logo hover highlight with the main sidebar logo.

Terminal work

  • Replaces the fake line-buffered terminal backend with a real PTY-backed shell.
  • Vendors/adapts the working openpty pieces locally under gui/src-backend/pty so Codegraff does not depend on an external Zig package for PTY support.
  • Adds Codegraff-owned PTY process lifecycle on top of openpty:
    • spawn shell in workspace cwd;
    • write raw input to PTY;
    • stream raw PTY output over existing SSE terminal-output events;
    • resize with TIOCSWINSZ;
    • terminate/close sessions cleanly.
  • Fixes terminal sizing and removes output normalization that corrupted full-screen/TUI apps like claude/codex.
  • Auto-copies terminal text selections with stale-guarded serialized clipboard writes and a dismissible toast.
  • Preserves immediate terminal exit/error status instead of clearing it during startup replay.

Harness invocation fixes

  • Updates GUI backend to match the installed graff --json protocol.
  • Removes unsupported --resume <session> CLI args from GUI-launched graff children.
  • Uses the current legacy-compatible set_model request shape: { "type": "set_model", "name": "provider/model" }.
  • Keeps core harness changes aligned with the release baseline except for the intentional Kimi provider-login mapping/test update.

Verification

Ran:

zig build test
cd gui && bunx tsc -b --pretty false
cd gui && bun test src/components/ui/ButtonGroup.test.ts src/components/promptHistoryNavigation.test.ts src/components/attachments/attachmentTypes.test.ts
cd gui && bunx eslint src/components/general-settings/UpdatesCard.tsx src/components/ui/ButtonGroup.tsx src/services/updater.ts src/components/workspace-board/hooks/useTerminalSession.ts src/components/ProjectSidebar.tsx
cd gui && bun run build
cd gui && zig build native

Manual / visual checks:

  • GUI launches through MerJS native shell.
  • Prompt submission reaches graff --json without the old --resume failure.
  • PTY terminal opens real shell sessions.
  • claude/codex style TUI output renders correctly after terminal sizing/raw output fixes.
  • Sidebar footer no longer overlays the Projects header/action when the chats list is long.
  • Settings-view Codegraff logo uses the same hover/highlight treatment as the main sidebar logo.
  • Kuri/Chrome was used for a browser visual smoke check of the rebuilt GUI bundle where possible.

@pranavp311

Copy link
Copy Markdown
Contributor Author

Updated this PR with Codegraff GUI fixes in b269fad (pushed to backend-zig-merjs-regression-fixes):

  • Stabilized Enter submit flow by making Enter use the textarea DOM value, pass explicit workspace/conversation context, and guard duplicate rapid submits.
  • Added optimistic user-message rendering with rollback on send failure.
  • Fixed chat auto-titling/persistence so CLI-compatible session files import/retitle from the first user message and persist durable titles.
  • Hid empty New Chat drafts from the Chats sidebar and stopped writing empty draft session files before the first message.
  • Added Codegraff-side Cmd+W wiring via the native bridge client/manifest.

Validated in Codegraff GUI:

  • bunx tsc -b
  • zig build

Note: native shell bridge implementation for window.close is committed separately to the MerJS PR (#100), not this Codegraff PR.

@pranavp311
pranavp311 force-pushed the backend-zig-merjs-regression-fixes branch from 4a68c43 to d0c065d Compare June 27, 2026 04:55
@pranavp311
pranavp311 changed the base branch from release/0.0.163 to release/0.0.177 June 27, 2026 08:06
Forward per-message text deltas (message-delta) and request
finished/cancelled lifecycle events from the GUI backend (runtime.zig)
through SSE to the frontend. Wire the new listeners in useSessionBootstrap
and add appendMessageDelta / markRequestFinished handlers to the session
store so streaming text and request completion update the UI without a
full snapshot round-trip.
…answer panel

buildChatThreadItems now emits each contiguous run of tool activity as its
own request_work segment in place, so a turn reads
[user][work][text][work][text] instead of hoisting all tool work above all
text. The last work segment of a scope is flagged isFinalSegment (carries
turn timing); the last assistant message of a turn is flagged isFinalAnswer
and renders as a distinct answer panel (accent rail + tint + "Answer"
eyebrow). Reasoning/thinking blocks use a subtler muted italic tone.

Also: per-segment activity keys now include the segment index (fixes a
duplicate-key collision across segments in one scope), ChatThread memoizes
its items and passes activeRequestIds through, ActivityResultCard gains a
tone accent strip, the enter-submit lock is shortened, and the QA mock
send_prompt returns a showcase interleaved transcript for visual verification.
.graff/ holds ephemeral harness subagent reports; plans/ and shots/ are
local handoff docs and visual-QA screenshots. The already-tracked
plans/2026-06-15-gui-port-review-handoff.md stays tracked.
- FinalAnswerMessage: premium minimal card on semantic tokens (hairline
  accent gradient, faint wash, inset ring, elevation-sm) tracking every
  preset x light/dark; dropped chunky border box/accent rail/eyebrow.
- FollowupComposer: redesigned to match the prompt-input card shell
  (max-w-3xl, same border/bg/padding). Highlightable options rendered
  above a labeled 'Notes' box; Enter submits selected option(s) + notes,
  Shift+Enter newline. Theme-consistent font weights.
- QA mock: followup-with-options scenario + respond_followup mock case
  for visual verification.
@pranavp311
pranavp311 force-pushed the backend-zig-merjs-regression-fixes branch from d0c065d to 1727d91 Compare June 27, 2026 08:12
@justrach

Copy link
Copy Markdown
Owner

@codegraff-bot You're checked out on this PR's branch. Run git diff origin/main...HEAD --stat and inspect the key changed files, then give a concise, actionable code review: correctness risks, design concerns, and the most important things to fix before merge. Reference specific files.

Repository owner deleted a comment from codegraff-bot Bot Jun 30, 2026
Repository owner deleted a comment from codegraff-bot Bot Jun 30, 2026
@justrach

justrach commented Jul 1, 2026

Copy link
Copy Markdown
Owner

@codegraff-bot please review this PR. Focus on: the MerJS local-path dependency in gui/build.zig.zon (fresh-checkout buildability), the Zig backend runtime + MerJS route table, the new PTY-backed terminal lifecycle, and any native GUI regressions in the reworked shell.

@codegraff-bot

codegraff-bot Bot commented Jul 1, 2026

Copy link
Copy Markdown

On it — I picked up:

please review this PR. Focus on: the MerJS local-path dependency in gui/build.zig.zon (fresh-checkout buildability), the Zig backend runtime + MerJS route table, the new PTY-backed terminal lifecycle, and any native GUI regressions in the reworked shell.

Running on your Codegraff account — I'll post my review here when it's done.

@codegraff-bot

codegraff-bot Bot commented Jul 1, 2026

Copy link
Copy Markdown

I now have a complete picture. Let me compile the final review.


PR Review: GUI Backend Rework (Tauri → Zig + MerJS + PTY Terminal)

Branch: prhead — ~80 commits rebuilding the desktop GUI backend from a Tauri/Rust host to an embedded Zig HTTP server (MerJS framework) with native WKWebView shell, SSE streaming, and PTY terminal sessions.


1. CRITICAL — Build/Correctness Blockers

C1. merjs dependency uses a local path that doesn't exist in-repo

gui/build.zig.zon:6

.merjs = .{ .path = "../../../merjs" },

This resolves three directories above the repo root. No vendored merjs/ directory exists in the repository, and the top-level build.zig.zon uses a proper remote URL for its only dependency (zigzag). The entire GUI depends on mer and runtime modules extracted from this package — build.zig:27–30, native/main.zig:3–4, runtime.zig:1–2, all 63 API route stubs.

Impact: zig build native from a fresh clone fails immediately. Nobody can build, test, or package the GUI.

Fix: Either (a) vendor merjs into the repo (like a packages/merjs/ directory), (b) convert to a .url / .hash remote dependency matching the convention used by zigzag in the top-level build.zig.zon, or (c) document the required sibling-directory layout and add a bootstrap script. Option (b) is strongly preferred for reproducibility.


C2. Shutdown use-after-free — detached threads outlive Runtime.deinit()

gui/native/main.zig:61–62 + gui/src-backend/runtime.zig:581, 1489

var rt = try backend.Runtime.init(allocator);
defer rt.deinit();                   // ← frees arena, destroys Runtime
try native.Shell.run(...);           // ← blocks until window closes

Both terminal reader threads (terminalReaderMain, line 1489: thread.detach()) and graff turn threads (graffTurnThread, line 581: thread.detach()) access rt internals — rt.mutex, rt.terminals, rt.emitSseEvent(), rt.allocatorafter Shell.run() returns and deinit() begins freeing memory.

Concrete crash sequence:

  1. User has a terminal open → window closes → Shell.run() returns.
  2. deinit() iterates self.terminals, calls closeTerminalSession() → sends SIGHUP, closes master_fd.
  3. deinit() proceeds to self.terminals.deinit(), self.arena_state.deinit(), self.allocator.destroy(self).
  4. Meanwhile, terminalReaderMain's read() returns error → calls session.proc.wait() (blocking waitpid).
  5. Reader thread finishes waitpid → calls rt.emitTerminalExit(session, exit_code) → calls rt.emitSseEvent()rt.allocator.dupe()accesses freed memory.
  6. Reader thread then calls rt.mutex.lockUncancelable() on a destroyed mutex.

Fix: Before rt.deinit(), signal all sessions (set .closing = true + SIGHUP) and join every spawned thread instead of detaching. Store std.Thread handles in TerminalSessionState and GraffSession, and join them during cleanup. A std.Thread.Condition or an atomic threads_active counter that each thread decrements on exit would work as a barrier.


2. MAJOR — Resource Leaks, Silent Data Loss, UX Gaps

M1. SSE EventSource: zero onerror handling → silent UI freeze

gui/src/services/desktop/client.ts:568–588

ensureSessionEventSource() creates new EventSource("/events") with listeners for 8 event types but no onerror handler. When the SSE stream drops (server restart, network blip):

  • Token-by-token message-delta events silently stop arriving.
  • request-finished never fires → the "stop" button stays active, composer may be disabled.
  • The 5-second poll fallback (getSessionSnapshot) only delivers snapshots, not streaming tokens.
  • User sees a frozen partial response with no error indicator and no retry mechanism.

Fix: Add es.onerror that checks es.readyState, surfaces a "reconnecting" banner, and optionally falls back to polling for missed events during the CLOSED state.


M2. 63 silent catch {} drops on state mutations

gui/src-backend/runtime.zig — lines 398, 413, 464, 487, 538, 555, 556, 573, 657, 897, 898, 899, 2065, 2109, 2121, 2188, 2189, 2299, 2658, 2774, 2790, 2793, 2809, 3058, 3109, and ~37 more.

Particularly dangerous examples:

// Line 898-899: chat messages silently lost
conv.messages.append(self.arena, .{ .kind = .user, ... }) catch {};
conv.messages.append(self.arena, .{ .kind = .assistant, ... }) catch {};

// Line 2065: streaming deltas silently dropped
conv.messages.append(self.arena, .{ .kind = kind, ... }) catch {};

// Line 398: conversation selection silently fails
self.selected_by_workspace.put(wpath, conversation.conversation_id) catch {};

Under memory pressure, messages are silently lost — the user sends a prompt, the backend processes it, but the response is never recorded in the conversation. The UI shows the old state on next poll.

Fix: For message appends, propagate the error to the HTTP response (the caller can retry). For non-critical state (selections, caches), at minimum log the error. catch {} on data-mutating operations should be treated as a bug.


M3. Arena-only allocation for terminal sessions — no per-session deallocation

gui/src-backend/runtime.zig:1438:

const session = self.arena.create(TerminalSessionState) catch return oom();

When a terminal closes (terminalClose, line ~1544), the session is removed from the terminals HashMap, but its memory (TerminalSessionState struct + all dupe'd strings: terminal_id, instance_id, workspace_path, cwd, shell, scrollback buffer) is never freed — it lives until Runtime.deinit().

A user who opens/closes 100 terminal sessions in a long session leaks ~100 × (struct + strings) of arena memory that's never reclaimed. The arena grows monotonically.

Fix: Allocate terminal sessions on the main allocator (not the arena) so they can be properly freed in closeTerminalSession. Or use a dedicated sub-allocator for terminals.


M4. PTY closeTerminalSession doesn't waitpid — zombie processes

gui/src-backend/pty/process.zig:71–73 + runtime.zig:3845–3852:

fn closeTerminalSession(session: *TerminalSessionState) void {
    if (session.closing.swap(true, .acq_rel)) return;
    session.io_mutex.lockUncancelable(mer_runtime.io);
    defer session.io_mutex.unlock(mer_runtime.io);
    session.proc.terminate();   // SIGHUP
    session.proc.close();       // closes master_fd
    // ← no waitpid!
}

After SIGHUP + close, the child process becomes a zombie until the reader thread happens to call wait() — but if the reader thread has already exited (or was never started due to thread spawn failure), the zombie persists until the parent exits. Also, the close() happens while the reader thread may still be calling read() on the same fd — there's a TOCTOU race between the closing flag check in the reader loop and the actual fd close.

Fix: The closing flag and io_mutex help, but the sequence should be: (1) set closing = true, (2) terminate() (SIGHUP), (3) close master_fd, (4) join the reader thread (which calls wait()), then safe to free.


3. MINOR — Code Quality, Naming, Edge Cases

m1. Global mutable singleton

gui/src-backend/runtime.zig:17:

pub var instance: ?*Runtime = null;

This is accessed from api/stub.zig and (potentially) from multiple threads. The write-then-read pattern in init()/deinit() has no acquire/release semantics on the store (only on reads elsewhere). This works in practice because init completes before any request, but it's fragile.


m2. PTY fallback execve drops environment variables

gui/src-backend/pty/process.zig:139–140:

_ = std.c.execve("/usr/bin/env", @ptrCast(argv.ptr), std.c.environ);
_ = std.c.execve(shell_z.ptr, @ptrCast(argv[6..].ptr), std.c.environ);

The fallback uses argv[6..] (just the shell + -l) but still with std.c.environ — so TERM, COLUMNS, LINES are lost. Minor since this only triggers if /usr/bin/env is missing, but it means the fallback shell has no terminal size information.


m3. All routes in routes.zig point to the same stub.render

gui/routes.zig: All 63 API routes dispatch through stub.renderbackend.instance.handleApi(req) → a giant if/else chain. This means:

  • Route matching is O(n) string comparisons per request.
  • A single bug in handleApi can take down all endpoints.
  • The route table doesn't document which routes accept which parameters or HTTP methods (all are POST-only, enforced inside handleApi).

Consider extracting the dispatch into a comptime-generated switch or moving route-specific logic into individual api/*.zig files (which are already wired as modules in build.zig's addApiModules but unused).


m4. SSE event replay buffer unbounded growth before trim

gui/src-backend/runtime.zig emitSseEvent(): Events are appended and only trimmed when events.items.len > sse_event_replay_limit. No limit on individual event data size. A single large tool output (e.g., a 10MB file read) would be stored verbatim in the replay buffer.


m5. terminalInstanceMatches accepts null unconditionally

gui/src-backend/runtime.zig:1664:

fn terminalInstanceMatches(_: *Runtime, session: *TerminalSessionState, requested: ?[]const u8) bool {
    return requested == null or std.mem.eql(u8, session.instance_id, requested.?);
}

Any client that omits terminalInstanceId from the request gets a match on any session. This weakens the instance-staleness guard — a stale frontend tab can accidentally write to a recycled terminal id.


4. Positive Observations

  • Clean architecture split: The native/main.zigbuild.zigroutes.zigapi/stub.zigruntime.zig layering is clear. The MerJS integration is minimally invasive.
  • PTY module is solid: gui/src-backend/pty/ is well-structured with proper openptyforksetsidTIOCSCTTYdup2execve chain, UTF-8 sanitization in the reader, and scrollback truncation with a configurable limit.
  • SSE design: The version-based + event-seq dual tracking, Last-Event-ID reconnect support, and bounded replay buffer show good SSE hygiene.
  • Frontend migration: The invokeCommandhttpInvoke migration in client.ts cleanly abstracts the transport change. No stale Tauri @tauri-apps/api imports remain.
  • Test coverage: The runtime.zig file includes inline test blocks (visible in the catch {} list at lines 5318–5405) covering conversation persistence, message append, and event replay — good foundation.

Summary

Severity Count Key Items
CRITICAL 2 C1: broken merjs dep (can't build), C2: shutdown UAF (crash on exit)
MAJOR 4 M1: silent SSE disconnect, M2: 63 silent data drops, M3: arena leak, M4: zombie procs
MINOR 5 Global singleton, fallback env, route dispatch, SSE size, instance guard

Recommendation: Block merge on C1 (buildability) and C2 (memory safety). Address M1–M4 before any user-facing release. The codebase shows strong architectural intent and the PTY/SSE foundations are good — these are the kinds of issues expected in a large backend rewrite that just need a focused cleanup pass.

🤖 codegraff-bot · ⚡ fusion → mimo-v2.5-pro
💸 $0.351 billed to your Codegraff account · 2680k in (2005k cached) + 57.9k out · 147 calls

@justrach

justrach commented Jul 1, 2026

Copy link
Copy Markdown
Owner

@codegraff-bot fix the C2 shutdown use-after-free: detached terminal reader threads (terminalReaderMain) and graff turn threads (graffTurnThread) call thread.detach() and then touch rt internals (rt.mutex, rt.terminals, rt.emitSseEvent, rt.allocator) after Shell.run() returns and Runtime.deinit() frees them. Store the std.Thread handles in TerminalSessionState and the graff session state, set closing=true + SIGHUP before deinit, and join every spawned thread during cleanup instead of detaching, so no thread outlives the Runtime. Scope this to the shutdown/thread-lifecycle path only.

@codegraff-bot

codegraff-bot Bot commented Jul 1, 2026

Copy link
Copy Markdown

On it — I picked up:

fix the C2 shutdown use-after-free: detached terminal reader threads (terminalReaderMain) and graff turn threads (graffTurnThread) call thread.detach() and then touch rt internals (rt.mutex, rt.terminals, rt.emitSseEvent, rt.allocator) after Shell.run() returns and Runtime.deinit() frees them. Store the std.Thread handles in TerminalSessionState and the graff session state, set closing=true + SIGHUP before deinit, and join every spawned thread during cleanup instead of detaching, so no thread outlives the Runtime. Scope this to the shutdown/thread-lifecycle path only.

Running on your Codegraff account — I'll post my review here when it's done.

@codegraff-bot

codegraff-bot Bot commented Jul 1, 2026

Copy link
Copy Markdown

I prepared a fix for:

fix the C2 shutdown use-after-free: detached terminal reader threads (terminalReaderMain) and graff turn threads (graffTurnThread) call thread.detach() and then touch rt internals (rt.mutex, rt.terminals, rt.emitSseEvent, rt.allocator) after Shell.run() returns and Runtime.deinit() frees them. Store the std.Thread handles in TerminalSessionState and the graff session state, set closing=true + SIGHUP before deinit, and join every spawned thread during cleanup instead of detaching, so no thread outlives the Runtime. Scope this to the shutdown/thread-lifecycle path only.

⚠️ verify skipped (no zig toolchain in sandbox)

Not pushing — this PR is from a fork, so I can't push to its branch. Patch to apply:

diff --git a/gui/src-backend/runtime.zig b/gui/src-backend/runtime.zig
index 6a8fb80..73a67d3 100644
--- a/gui/src-backend/runtime.zig
+++ b/gui/src-backend/runtime.zig
@@ -123,6 +123,7 @@ const TerminalSessionState = struct {
     io_mutex: std.Io.Mutex = .init,
     closing: std.atomic.Value(bool) = .init(false),
     exited: std.atomic.Value(bool) = .init(false),
+    reader_thread: ?std.Thread = null,
 };
 
 const GraffSession = struct {
@@ -162,6 +163,8 @@ pub const Runtime = struct {
     arena_state: std.heap.ArenaAllocator,
     arena: std.mem.Allocator,
     mutex: std.Io.Mutex = .init,
+    closing: std.atomic.Value(bool) = .init(false),
+    pending_threads: std.ArrayList(std.Thread) = .empty,
     version: std.atomic.Value(u64) = .init(1),
     workspaces: std.ArrayList(Workspace) = .empty,
     saved_workspaces: std.ArrayList(SavedWorkspace) = .empty,
@@ -208,11 +211,33 @@ pub const Runtime = struct {
     }
 
     pub fn deinit(self: *Runtime) void {
-        var it = self.terminals.valueIterator();
-        while (it.next()) |session| closeTerminalSession(session.*);
+        // Phase 1: signal shutdown so threads bail out of hot loops.
+        self.closing.store(true, .release);
+
+        // Phase 2: close every terminal (sets closing=true, sends SIGHUP,
+        // closes the PTY master fd so terminalReaderMain unblocks) and kill
+        // every graff child (so graffTurnThread sees EOF on stdout).
+        {
+            var it = self.terminals.valueIterator();
+            while (it.next()) |session| closeTerminalSession(session.*);
+        }
+        {
+            var graff_it = self.graff_sessions.valueIterator();
+            while (graff_it.next()) |session| self.closeGraffSession(session.*);
+        }
+
+        // Phase 3: join every thread we ever spawned.  Threads that already
+        // exited return immediately from join(); blocked threads are unblocked
+        // by the close/kill above.  The Runtime is still fully alive at this
+        // point, so any final `self.mutex` / `self.terminals` / `self.arena`
+        // access inside the threads is safe.
+        for (self.pending_threads.items) |thread| {
+            thread.join();
+        }
+        self.pending_threads.deinit(self.allocator);
+
+        // Phase 4: tear down data structures — no thread can touch them now.
         self.terminals.deinit();
-        var graff_it = self.graff_sessions.valueIterator();
-        while (graff_it.next()) |session| self.closeGraffSession(session.*);
         self.graff_sessions.deinit();
         self.active_children.deinit();
         var cache_it = self.runtime_status_cache.iterator();
@@ -578,7 +603,9 @@ pub const Runtime = struct {
             self.mutex.unlock(mer_runtime.io);
             return self.jsonResponse(req, self.snapshotJson(req.allocator) catch return oom());
         };
-        thread.detach();
+        self.mutex.lockUncancelable(mer_runtime.io);
+        self.pending_threads.append(self.allocator, thread) catch {};
+        self.mutex.unlock(mer_runtime.io);
 
         return self.jsonResponse(req, self.snapshotJson(req.allocator) catch return oom());
     }
@@ -1486,7 +1513,10 @@ pub const Runtime = struct {
             closeTerminalSession(session);
             return mer.Response.init(.internal_server_error, .json, "{\"error\":\"failed to start terminal reader\"}");
         };
-        thread.detach();
+        session.reader_thread = thread;
+        self.mutex.lockUncancelable(mer_runtime.io);
+        self.pending_threads.append(self.allocator, thread) catch {};
+        self.mutex.unlock(mer_runtime.io);
 
         return terminalSessionJson(req.allocator, terminal_id, session.instance_id, workspace, shell, cols, rows, "", false, 0);
     }
@@ -1704,6 +1734,7 @@ pub const Runtime = struct {
     }
 
     fn ensureGraffSession(self: *Runtime, conversation_id: []const u8, session_name: []const u8, workspace: []const u8, agent_id: []const u8, plan_mode: bool, provider: ?[]const u8, model: ?[]const u8) !*GraffSession {
+        if (self.closing.load(.acquire)) return error.RuntimeShuttingDown;
         const io = mer_runtime.io;
         const desired_yolo = !plan_mode and std.mem.eql(u8, agent_id, "forge");
         self.mutex.lockUncancelable(io);

🤖 codegraff-bot · ⚡ fusion → mimo-v2.5-pro
💸 $0.091 billed to your Codegraff account · 1713k in (1564k cached) + 23.5k out · 50 calls

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.

2 participants