Skip to content

[Bug]: Orphaned t3 serve processes remain after closing app sessions, causing port leaks and high memory usage #2614

Closed

Description

@fionns-openclaw

Before submitting

  • I searched existing issues and did not find a duplicate.
  • I included enough detail to reproduce or investigate the problem.

Area

apps/server

Steps to reproduce

  1. Connect to a machine remotely through T3 Code.

  2. Start a T3/Codex app session that launches a local t3 serve process.

  3. Close the remote app/session, disconnect, or reconnect.

  4. Start another T3/Codex app session.

  5. Repeat this a few times.

  6. Check for remaining local servers:

    ps -eo pid,ppid,user,lstart,etime,rss,args | grep 't3 serve'

  7. Check listening ports:

    ss -ltnp | grep -E ':(377[0-9]|378[0-9]|379[0-9]|380[0-9])\b'

Expected behavior

When a remote T3 Code app/session is closed, disconnected, or reconnected, the associated t3 serve process and its child processes should be terminated.

Starting a new session should not leave old t3 serve processes running, and should not keep incrementing to new localhost ports because previous servers were leaked.

Actual behavior

I have only noticed this when using remote connections through T3 Code.

Old t3 serve processes appear to remain running after a remote app/session is closed, disconnected, or reconnected. My suspicion is that reconnecting starts a new local server but does not reliably shut down the previous one.

The old processes become orphaned with PPID=1, continue listening on localhost ports such as 3773, 3774, 3775, etc., and continue consuming memory.

After repeated remote sessions/reconnects, many stale t3 serve instances can accumulate and use several GB of RAM.

Impact

Blocks work completely

Version or commit

No response

Environment

No response

Logs or stack traces

Example stale process:

    node /home/fionn/.npm/_npx/057fe8ede92e4d7d/node_modules/.bin/t3 serve --host 127.0.0.1 --port 3773 --base-dir /home/fionn/.t3

Process inspection showed old servers orphaned under PID 1:

    ps -o pid,ppid,user,lstart,etime,rss,args -p <pid>

Example output:

    PID    PPID USER   STARTED                  ELAPSED      RSS    COMMAND
    5930   1    fionn  Sat May 9 12:23:14 2026  05:41   175888   node .../t3 serve --host 127.0.0.1 --port 3773 --base-dir /home/fionn/.t3

Port inspection showed multiple local servers still listening:

    ss -ltnp | grep -E ':(377[0-9]|378[0-9]|379[0-9]|380[0-9])\b'

Example output:

    LISTEN 0 511 127.0.0.1:3773 0.0.0.0:* users:(("node",pid=5930,fd=21))
    LISTEN 0 511 127.0.0.1:3774 0.0.0.0:* users:(("node",pid=6921,fd=21))

Aggregated memory usage from leaked instances:

    ps -eo rss=,args= | awk '/t3 serve --host 127.0.0.1/ {sum+=$1; count++} END {printf "t3 serve: %.1f MiB across %d processes\n", sum/1024, count}'

At one point this showed dozens of `t3 serve` processes using several GiB of RAM.

System impact seen in kernel logs:

    Out of memory: Killed process <pid> (qemu-system-x86)

The OOM event was not caused by one `t3 serve` process alone, but the accumulation of many leaked local servers contributed significantly to memory pressure.

Screenshots, recordings, or supporting files

No response

Workaround

Manually list stale T3 servers:

ps -eo pid,ppid,user,lstart,etime,rss,args | grep 't3 serve --host 127.0.0.1'

Check which ports are still listening:

ss -ltnp | grep -E ':(377[0-9]|378[0-9]|379[0-9]|380[0-9])\b'

Kill stale/orphaned T3 servers for the current user:

pkill -f 'node .*t3 serve --host 127.0.0.1 .*--base-dir /home/fionn/.t3'

Safer manual cleanup, only targeting orphaned processes where PPID=1:

ps -eo pid=,ppid=,args= |
  awk '$2 == 1 && /node .*t3 serve --host 127\.0\.0\.1 .*--base-dir \/home\/fionn\/\.t3/ {print $1}' |
  xargs -r kill

After cleanup, verify:

pgrep -af 't3 serve --host 127.0.0.1'
ss -ltnp | grep -E ':(377[0-9]|378[0-9]|379[0-9]|380[0-9])\b'

A possible product-side workaround would be to have T3 Code detect existing t3 serve instances for the same --base-dir on reconnect and either reuse them or explicitly terminate stale orphaned ones before starting a new server.

Activity

  1. added
    bugSomething is broken or behaving incorrectly.
    needs-triageIssue needs maintainer review and initial categorization.
    on May 9, 2026
  2. juliusmarminge commented on May 9, 2026

    @juliusmarminge
    Member

    I am not sure i understand the issue. Can you clarify what commands and actions you run on either machine?

  3. juliusmarminge commented on Jul 20, 2026

    @juliusmarminge
    Member

    Please provide the exact local or remote launch path, parent and child PIDs, command line, expected shutdown trigger, and the sequence used to close the app/session. Remote lifecycle ownership has changed since this report and those details are needed to identify a current finalizer bug.\n\n

  4. added
    needs more infoInitial triage showed no bug. Awaiting more info
    and removed
    needs-triageIssue needs maintainer review and initial categorization.
    on Jul 20, 2026
  5. fionn77 commented on Aug 4, 2026

    @fionn77
    Contributor

    Thanks for following up, and sorry the original report was too broad. I reconstructed the May 8-9 setup from retained process snapshots and launcher state.

    A correction first: this was the Windows desktop app managing an SSH remote. It was not an Android/mobile launch, and the original report was observational (accumulated leaked processes), not a controlled one-close/one-tree capture.

    Controller and launch path

    • Controller: Windows PC Gartlan-PC
    • App: T3 Code (Alpha) 0.0.22
    • Executable: C:\Users\fioei\AppData\Local\Programs\t3code\T3 Code (Alpha).exe
    • SSH target at the time: fionn@192.168.178.154:22 (later saved as the alias gartlanserver)
    • Remote home/base dir: /home/fionn / /home/fionn/.t3
    • I did not manually run t3 serve in a shell. Selecting/connecting to that SSH environment in the desktop app launched it.

    The retained generated runner for the original IP-keyed target was:

    /home/fionn/.t3/ssh-launch/e42bd3ef49e8b99b/run-t3.sh
    

    After the same target was saved under the gartlanserver SSH alias, its state key/path was:

    /home/fionn/.t3/ssh-launch/8105bf2744a76211/run-t3.sh
    

    The remote runner was using t3@0.0.22 and launched the server with T3CODE_NO_BROWSER=1, loopback host, a scanned port starting at 3773, and --base-dir /home/fionn/.t3.

    One potentially relevant local detail: at first the app could be invoked both manually and by this plain Windows Startup shortcut:

    C:\Users\fioei\AppData\Roaming\Microsoft\Windows\Start Menu\Programs\Startup\T3 Code (Alpha).lnk
      -> C:\Users\fioei\AppData\Local\Programs\t3code\T3 Code (Alpha).exe
    

    I noticed that after login I could manually start T3 Code and then have the Startup shortcut invoke it again. I changed that shortcut to an "already running" guard on May 8. Leaked remote processes had already been observed before that change, so duplicate/local startup may have been a contributing path but does not account for the whole history by itself.

    Captured process tree and commands

    A complete remote chain captured from the same affected version/configuration was:

    PID 2221501  PPID 1        npm exec t3@0.0.22 serve --host 127.0.0.1 --port 3802 --base-dir /home/fionn/.t3
    PID 2221586  PPID 2221501  sh -c t3 serve --host 127.0.0.1 --port 3802 --base-dir /home/fionn/.t3
    PID 2221587  PPID 2221586  node /home/fionn/.npm/_npx/057fe8ede92e4d7d/node_modules/.bin/t3 serve --host 127.0.0.1 --port 3802 --base-dir /home/fionn/.t3
    

    Other older leaked children from that run had already lost the npm/shell wrappers and were reparented directly to PID 1, for example:

    PID 23343  PPID 1  node .../.bin/t3 serve --host 127.0.0.1 --port 3773 --base-dir /home/fionn/.t3
    PID 53359  PPID 1  node .../.bin/t3 serve --host 127.0.0.1 --port 3774 --base-dir /home/fionn/.t3
    

    The May 9 snapshot included:

    PID 5930  PPID 1  node .../.bin/t3 serve --host 127.0.0.1 --port 3773 --base-dir /home/fionn/.t3
    PID 6921  PPID 1  node .../.bin/t3 serve --host 127.0.0.1 --port 3774 --base-dir /home/fionn/.t3
    

    I did not capture the pre-orphan parent PIDs for 5930/6921. By the time I inspected them their parent was already PID 1, so I cannot provide the original launcher PIDs for those two without guessing.

    Expected shutdown trigger and the actual sequence

    The shutdown trigger I expected was either:

    1. explicitly disconnecting that SSH environment in T3 Code, or
    2. fully quitting/tearing down the Windows desktop app.

    That should close the local ssh -N -L ... tunnel and run the remote managed-server stop/finalizer. I no longer claim that merely leaving or closing a Codex thread should necessarily stop an environment-level server; the original phrase "close the remote app/session" conflated thread navigation with environment lifecycle.

    The sequence used during the accumulated observation was:

    1. Sign in to Windows and start T3 Code (initially this could happen manually and again through the Startup shortcut).
    2. Select/connect to the fionn@192.168.178.154 SSH environment.
    3. Open and use a Codex thread on that environment.
    4. Return out of that remote view; during different repetitions I also used the environment's disconnect/reconnect path or quit/reopened the desktop app.
    5. Select the remote again.
    6. Observe another server on the next loopback port while earlier node ... t3 serve children remained.
    7. Inspect with ps and ss.

    The important limitation is that the May 9 PIDs were not tagged to one isolated UI-close action. I cannot honestly say whether PID 5930 specifically survived explicit Disconnect, app Quit, or an app restart/duplicate launch. The confirmed facts are the launcher path/command, the process trees above, and that the older node children were still listening after their wrappers/owners were gone.

    Since lifecycle ownership has changed, I agree the most useful next step would be a fresh controlled capture on the current build: tree before launch, tree after explicit Disconnect, and tree after a full app Quit. I can do that if this historical detail is not enough to map the old report to the current finalizer.

  6. acomarcho commented on Sep 3, 2026

    @acomarcho

    Independent reproduction on 0.0.35, plus what looks like the concrete root cause for the orphaning described here. The original report's suspicion ("reconnecting starts a new local server but does not reliably shut down the previous one") is correct, and the reason it cannot shut it down is a PID recorded one layer too high.

    Root cause: the recorded PID is the npm wrapper, not the server

    REMOTE_LAUNCH_SCRIPT in packages/ssh/src/tunnel.ts starts the remote server and records $!:

    nohup env T3CODE_NO_BROWSER=1 "$RUNNER_FILE" serve --host 127.0.0.1 --port "$REMOTE_PORT" --base-dir "$DEFAULT_SERVER_HOME" >>"$LOG_FILE" 2>&1 < /dev/null &
    REMOTE_PID="$!"                       # tunnel.ts:594
    printf '%s\n' "$REMOTE_PID" >"$PID_FILE"

    $RUNNER_FILE (run-t3.sh) only execs straight into the server when a global t3 is on PATH. When it is not, it falls through to the npx path:

    exec npx --yes 't3@<version>' "$@"

    npx is npm exec, which does not exec-chain into the resolved CLI. It spawns sh -c "t3" ..., which spawns the actual node process. So $! is the npm exec process, two layers above the server that actually binds the port and opens SQLite.

    Captured from a live remote host while it was in this failure state:

    2618056  npm exec t3@0.0.35 serve --host 127.0.0.1 --port 3773 --base-dir ~/.t3    <- this is what lands in $PID_FILE
      2618094  sh -c "t3" serve --host 127.0.0.1 --port 3773 --base-dir ~/.t3
        2618095  node ~/.npm/_npx/<hash>/node_modules/.bin/t3 serve ... --port 3773    <- holds the port + state.sqlite
    

    Every kill "$REMOTE_PID" in the launch script therefore signals 2618056 and never reaches 2618095.

    Reproduction of the signal behaviour

    Minimal package in the same three-layer shape (nohup env ... runner.sh & -> exec npm exec -> sh -c -> node listener), then SIGTERM the pid that $! returned:

    # fakepkg/bin/fakesrv.mjs: #!/usr/bin/env node + net.createServer().listen(39997)
    # runner.sh: #!/bin/sh
    #            exec npm exec --yes --package ./fakepkg -- fakesrv
    
    nohup env FAKE_NO_BROWSER=1 ./runner.sh >>out.log 2>&1 </dev/null &
    WRAPPER=$!
    sleep 9
    REAL=$(ss -ltnp | grep 39997 | grep -oP 'pid=\K[0-9]+')
    kill "$WRAPPER"

    Result:

    WRAPPER pid from $!  (production writes THIS to $PID_FILE) = 2625221
    REAL server pid (holds port 39997)                         = 2625235
    
      pid=2625235 :: node .../node_modules/.bin/fakesrv
      pid=2625234 :: sh -c "fakesrv"
      pid=2625221 :: npm exec fakesrv
    
    === SIGTERM the recorded wrapper (launcher's `kill $REMOTE_PID`) ===
      wrapper 2625221   alive? NO
      REAL    2625235   alive? YES  <-- ORPHANED, SURVIVED THE KILL
      port 39997 bound?      YES  <-- PORT STILL HELD
      REAL reparented to ppid=1
    

    npm does not forward the signal through the intervening sh -c, so the server survives, reparents to init, and keeps both its listening socket and its SQLite connection.

    Why this yields the incrementing ports

    Once the wrapper is dead and the server is not, the launcher's own bookkeeping is wrong in a way that guarantees escalation:

    1. kill -0 "$REMOTE_PID" now fails, so the script treats the state as stale and clears $PID_FILE / $PORT_FILE / $MANAGED_FILE.
    2. pick_port scans upward from the last recorded port, window 200. The orphan still holds the previous port, so it returns the next one.
    3. The new server is launched with the same --base-dir "$DEFAULT_SERVER_HOME", so it opens the same state.sqlite as every orphan before it.

    On the host I looked at this produced nine distinct ports over the logs, 3773 through 3781, five of them live at once, all against one database. Contention then surfaces as repeated PersistenceSqlError ... OrchestrationEventStore.append:insert with Error: database is locked (463 occurrences across two log files), and codex threads failing with thread <id> already has an active writer because the provider is still attached to a server the desktop has abandoned.

    Worth noting the contention is entirely from duplication, not data volume: with a single server alive on the same 3.2 GB database, GET / and /.well-known/t3/environment both answer in 1-5 ms.

    Fix material already present but unused

    server-runtime.json records the real server pid, and resolve_default_runtime_port in the same script already reads it and liveness-checks it with process.kill(pid, 0). The kill path just prefers the wrapper:

    PID_TO_STOP="${REMOTE_PID:-$DEFAULT_RUNTIME_PID}"      # tunnel.ts:539

    Preferring the runtime-file pid, or having the server write its own pid to $PID_FILE after binding, would make the existing teardown work. A process-group kill would be an alternative, since nohup ... & does not create a new process group on its own.

    Still current

    packages/ssh/src/tunnel.ts is byte-identical between v0.0.35 and main at 5b8445b7a777, so this is unchanged as of 0.0.38.

    Related: #5749 covers why the replacement server gets started in the first place (the 2s reuse readiness budget), and #6097 covers the missing single-ownership check on the database. This issue is the third leg: why the old one never goes away.

    Environment

    • Remote host: Linux x64 (7.0.0-30-generic), Node v24.16.0, t3 server 0.0.35 (pinned by the desktop's SSH launcher as t3@0.0.35), no global t3 on PATH so the npx path is taken
    • No systemd user unit installed (t3 service status reports not installed), so every server is launcher-managed
    • Desktop app on a separate machine, reaching this host over Tailscale via the SSH launcher

    Filed via t3 triage. Investigation and reproduction by Claude Opus 5 (1M context) running as the triage agent in Claude Code.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething is broken or behaving incorrectly.needs more infoInitial triage showed no bug. Awaiting more info

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions