Skip to content

feat(lume, computer-server): VNC backend rewrite and VirtioFS port discovery - #1205

Merged
f-trycua merged 4 commits into
mainfrom
lume-cua-vnc
Mar 23, 2026
Merged

feat(lume, computer-server): VNC backend rewrite and VirtioFS port discovery#1205
f-trycua merged 4 commits into
mainfrom
lume-cua-vnc

Conversation

@f-trycua

@f-trycua f-trycua commented Mar 23, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • VNC backend rewrite (vnc.py): Replaced vncdotool.api.connect() (which deadlocks inside uvicorn/asyncio on Python 3.13) with a global Twisted reactor pattern. Each VNC operation creates a fresh connection via reactor.callFromThread() + threading.Event synchronization. All closures use @defer.inlineCallbacks / yield. Also fixes mouseDrag (doPoll not available on SelectReactor) by using manual mouseMove increments, and fixes client.screen access.
  • VirtioFS port discovery (VM.swift, Run.swift, LumeController.swift, VNCService.swift): Lume now creates a temp "lume-config" VirtioFS shared directory, writes vnc.env (dynamic port + password) after VNC starts. The guest mounts the share and reads the config on boot — no more hardcoded VNC port/password. Includes a VNC URL parsing fix (URLComponents with vnc://http:// replacement) since Swift's URL can't parse the vnc:// scheme.
  • CLI env var fix (cli.py): CUA_VNC_PORT is now only overridden from CLI args when explicitly provided, preventing the default (5900) from clobbering the env var set by the guest's start_server.sh.
  • Setup script update (setup-cua.sh): Renamed from setup-cua-computer.sh. Added "already mounted" check (mount | grep -q "lume-config") so mount_virtiofs doesn't fail on server restarts. Moved vnc.env wait loop outside the mount if/else so it runs regardless of mount state.

Test plan

  • E2E test suite: 19/19 operations passing (screenshot, left/right/double click, key press, hotkey, type text, scroll up/down/generic, move cursor, get cursor position, get screen size, rapid screenshots, click+screenshot, drag, type/key aliases)
  • Verify VM boot with fresh lume run picks up dynamic VNC port from lume-config
  • Verify setup-cua.sh provisions a new VM correctly
  • Verify fallback to hardcoded defaults when VirtioFS share is unavailable

Summary by CodeRabbit

Release Notes

  • New Features
    • Added --vnc-password command-line option for configuring VNC authentication
    • Introduced noVNC support for browser-based VNC access
    • Added GUI auto-login capability
    • Implemented drag-and-drop operations in VNC automation
    • Enhanced scroll behavior in VNC interactions

…scovery

Rewrite the VNC backend to use Twisted's global reactor directly instead
of vncdotool's api.connect(), which deadlocks inside uvicorn/asyncio on
Python 3.13. Each VNC operation now creates a fresh connection via
reactor.callFromThread() with threading.Event synchronization.

Add VirtioFS-based VNC port discovery: lume creates a shared "lume-config"
directory, writes vnc.env (port + password) after VNC starts, and the
guest reads it on boot — eliminating hardcoded VNC port/password defaults.

Key changes:
- vnc.py: global reactor pattern, @defer.inlineCallbacks closures,
  manual drag (replaces mouseDrag/doPoll), client.screen fix
- VM.swift: VirtioFS lume-config share, VNC URL parsing fix
  (URLComponents with vnc:// → http:// replacement)
- cli.py: only override CUA_VNC_PORT from CLI when explicitly provided
- setup-cua.sh: renamed from setup-cua-computer.sh, added "already
  mounted" check for lume-config VirtioFS share

Tested E2E: 19/19 operations passing (screenshot, click, type, scroll,
drag, hotkey, cursor position, screen size).
@vercel

vercel Bot commented Mar 23, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
docs Ready Ready Preview, Comment Mar 23, 2026 11:49pm

Request Review

@github-actions

github-actions Bot commented Mar 23, 2026

Copy link
Copy Markdown
Contributor

📦 Publishable packages changed

  • lume
  • pypi/computer-server

Add release:<service> labels to auto-release on merge (+ optional bump:minor or bump:major, default is patch).
Or add no-release to skip.

@coderabbitai

coderabbitai Bot commented Mar 23, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto incremental reviews are disabled on this repository.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: b0d1c1e9-af83-44a6-a568-ca1d92d52c78

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The changes introduce optional VNC password configuration throughout the Lume VM startup pipeline, expand the CUA setup script with noVNC web interface support and auto-login capabilities, and refactor the Python VNC backend from persistent client connections to per-operation connections using Twisted reactor-based lifecycle management.

Changes

Cohort / File(s) Summary
VNC Password Support
libs/lume/src/Commands/Run.swift, libs/lume/src/LumeController.swift, libs/lume/src/VM/VM.swift, libs/lume/src/VNC/VNCService.swift, libs/lume/tests/Mocks/MockVNCService.swift
Added optional --vnc-password CLI flag that threads through Run command → LumeController → VM.run() → VNCService.start(), enabling user-specified VNC authentication instead of random password generation.
VM Configuration & VNC Exposure
libs/lume/src/VM/VM.swift
Created temporary read-only shared directory ("lume-config") for each VM run and writes vnc.env file containing extracted VNC port/password after VNC session initialization.
CUA Setup & noVNC Integration
libs/lume/scripts/setup-cua.sh
Expanded script scope to include noVNC/websockify installation, supervisor daemon setup, host gateway auto-detection, auto-login configuration, and modified startup script generation to route CUA VNC backend through host gateway via websockify proxy.
Python VNC Backend Refactoring
libs/python/computer-server/computer_server/handlers/vnc.py
Replaced persistent vncdotool client with per-operation connection factory using Twisted reactor; refactored screenshot, mouse/keyboard/clipboard operations; added drag operations; inverted scroll direction semantics.
VNC CLI Configuration
libs/python/computer-server/computer_server/cli.py
Modified CUA_VNC_PORT environment variable setting to conditionally override only when CLI value differs from default or env var is unset, preserving explicit environment configuration.

Sequence Diagram

sequenceDiagram
    participant User
    participant Run as Run Command
    participant Ctrl as LumeController
    participant VM as VM
    participant VNC as VNCService
    participant SharedDir as Shared Directory<br/>(lume-config)

    User->>Run: lume run --vnc-password secret
    Run->>Ctrl: runVM(vncPassword: "secret")
    Ctrl->>VM: run(vncPassword: "secret")
    VM->>VM: Create "lume-config"<br/>shared directory
    VM->>VNC: startVNCService(password: "secret")
    VNC->>VNC: Use provided password<br/>for VNC auth
    VNC-->>VM: Return vncInfo URL
    VM->>VM: Parse VNC port & password<br/>from vncInfo
    VM->>SharedDir: Write vnc.env<br/>(VNC_PORT, VNC_PASSWORD)
    SharedDir-->>VM: File written
    VM-->>User: VNC ready with<br/>custom password
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested labels

release:lume, release:pypi/computer-server

Suggested reviewers

  • ddupont808

Poem

🐰 A password, so secret, now flows through the wire,
From command to controller, through VM and VNC fire,
NoVNC opens the gates with websockify's art,
While vncdotool learns to make a fresh start!

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 35.19% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main changes: VNC backend rewrite and VirtioFS port discovery, reflecting the core modifications across multiple components.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch lume-cua-vnc

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@sentry

sentry Bot commented Mar 23, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@f-trycua f-trycua added release:lume Release lume on merge release:pypi/computer-server Release pypi/computer-server on merge labels Mar 23, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📦 Publishable packages changed

  • lume — will auto-release on merge
  • pypi/computer-server

Add release:<service> labels to auto-release on merge (+ optional bump:minor or bump:major, default is patch).
Or add no-release to skip.

@github-actions

Copy link
Copy Markdown
Contributor

📦 Publishable packages changed

  • lume — will auto-release on merge
  • pypi/computer-server — will auto-release on merge

- Fix isort ordering in vnc.py (twisted.internet imports)
- Add vncPassword parameter to MockVM.run() to match VM superclass
- Regenerate lume CLI and HTTP API reference docs
@github-actions

Copy link
Copy Markdown
Contributor

📦 Publishable packages changed

  • lume — will auto-release on merge
  • pypi/computer-server — will auto-release on merge

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 6

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
libs/lume/src/VNC/VNCService.swift (1)

72-109: ⚠️ Potential issue | 🟠 Major

Escape or validate custom VNC passwords before building the URL.

With the new password parameter, values containing reserved userinfo characters such as @, :, /, ?, or # will corrupt the assembled vnc URL below. That can break URLComponents parsing in the session loader and vnc.env writer even though VNC authentication itself succeeded.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@libs/lume/src/VNC/VNCService.swift` around lines 72 - 109, The code builds
vnc URL strings by interpolating raw password into "vnc://:\(password)@..."
which breaks if password contains reserved userinfo characters; update
start(port:password:virtualMachine:) to percent-encode or validate the password
before using it in URLs (keep self.vncPassword as the original for auth).
Replace manual string interpolation with URLComponents (scheme "vnc", host
"127.0.0.1"/hostIP, port Int(assignedPort)) and set the user/password via
URLComponents.user and URLComponents.password, or explicitly percent-encode the
password using addingPercentEncoding(withAllowedCharacters: .urlUserAllowed) and
use that encoded value when constructing the local and external URL strings; if
encoding fails, throw a VMError (e.g., vncInvalidPassword) to avoid emitting
malformed URLs.
🧹 Nitpick comments (1)
libs/lume/tests/Mocks/MockVNCService.swift (1)

32-35: Mirror the real VNC URL contract in the mock.

VM.run() now extracts both components.password and the assigned port from vncInfo. This mock still drops password and returns vnc://localhost:\(port), so tests bypass the new password/auto-port discovery path entirely.

Suggested fix
 func start(port: Int, password: String? = nil, virtualMachine: Any?) async throws {
     isRunning = true
-    url = "vnc://localhost:\(port)"
+    let resolvedPort = port == 0 ? 5901 : port
+    let resolvedPassword = password ?? "mock-password"
+    url = "vnc://:\(resolvedPassword)@localhost:\(resolvedPort)"
     _attachedVM = virtualMachine
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@libs/lume/tests/Mocks/MockVNCService.swift` around lines 32 - 35, The mock
start(port:password:virtualMachine:) currently ignores the password and always
returns "vnc://localhost:\(port)", so tests skip the VM.run() path that reads
vncInfo.components.password and the auto-assigned port; update
MockVNCService.start to populate url with both the password (when non-nil) and
the effective port (simulate auto-assigned port if port==0 by picking a non-zero
port), e.g. emit a vnc URL that includes the password component and the chosen
port, and leave isRunning and _attachedVM behavior unchanged so VM.run() can
read components.password and the assigned port from the mock vncInfo.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@libs/lume/scripts/setup-cua.sh`:
- Around line 419-425: The script currently uses eval on the contents of
LUME_CONFIG and later injects LUME_VNC_PASSWORD unescaped into shell and XML,
which allows code execution or malformed output; replace eval "$(cat
"$LUME_CONFIG")" with a safe parser that reads LUME_CONFIG line-by-line (e.g.,
while IFS='=' read -r key val; do case "$key" in VNC_PORT)
LUME_VNC_PORT="$val";; VNC_PASSWORD) LUME_VNC_PASSWORD="$val";; esac; done <
"$LUME_CONFIG" so values are not executed, preserve characters by using read -r
and no word-splitting, and when writing the password into shell scripts or
plist/XML use proper escaping (e.g., use printf '%s' "$LUME_VNC_PASSWORD" into
files and replace &,<,>,",\' with XML entities or write the value inside a
single-quoted shell heredoc or use xmllint/PlistBuddy when available) so special
characters like $,`,/,&,<,> are not interpreted or break the output; update
every injection site where LUME_VNC_PASSWORD or LUME_VNC_PORT is inserted
(references: LUME_CONFIG, LUME_VNC_PASSWORD, LUME_VNC_PORT, LOG_FILE) to use the
safe read and escaping approach.

In `@libs/lume/src/VM/VM.swift`:
- Around line 218-228: The deterministic lume-config host path (lumeConfigDir)
can serve stale vnc.env across runs; change creation to use a unique per-run
directory (e.g., append a UUID or run-timestamp to
"lume-config-\(vmDirContext.name)") and mirror this change for the USB-storage
path creation code, and ensure the directory is removed during VM shutdown/stop
(implement cleanup in the VM stop/teardown routine so the lumeConfigDir and the
USB-storage temp dir are deleted and not reused). Use the same unique-name
pattern where SharedDirectory is constructed (lumeConfigSharedDir and the USB
shared directory) and add removal logic in the VM stop/teardown function to
delete those temporary host directories.

In `@libs/python/computer-server/computer_server/cli.py`:
- Around line 123-125: The current check using "if args.vnc_port != 5900 or
'CUA_VNC_PORT' not in os.environ" cannot tell if the user explicitly passed
--vnc-port 5900; change the CLI to make args.vnc_port default to None (or
argparse.SUPPRESS) and then set the env only when the arg is provided: use "if
args.vnc_port is not None: os.environ['CUA_VNC_PORT'] = str(args.vnc_port)".
Update the argparse.add_argument call that defines vnc_port and the conditional
around os.environ["CUA_VNC_PORT"] accordingly.

In `@libs/python/computer-server/computer_server/handlers/vnc.py`:
- Around line 135-145: The reactor startup must be serialized to avoid
concurrent reactor.run() calls: add a module-level threading.Lock (e.g.,
reactor_start_lock) and a threading.Event (e.g., reactor_started_event), then in
_with_client() wrap the "if not reactor.running" check and reactor thread spawn
inside reactor_start_lock to ensure only one thread starts the reactor; after
starting the thread set reactor_started_event from the reactor thread (or
wait-loop until reactor.running is true) and have callers wait on
reactor_started_event.wait(timeout=...) before calling
reactor.callFromThread(_work) and done_event.wait(...). Reference: reactor,
reactor.run, _with_client(), done_event, threading.Thread; ensure you release
the lock quickly and use the event to avoid races where reactor.running is not
yet true immediately after starting the thread.
- Around line 117-156: The _with_client() flow never closes the VNC client
transport, leaking TCP sessions; update the inner _do() in _with_client to
always close the connection in its finally block: after obtaining client = yield
factory.deferred and after the work (or on exception), check if client is not
None and has a transport and call client.transport.loseConnection() (or
client.close() if the client exposes that) to ensure the TCP session is closed;
keep the done_event.set() but move/extend the finally to close the transport so
connections are cleaned up on both success and error (references: _with_client,
inner _do, factory.deferred, client.transport).

---

Outside diff comments:
In `@libs/lume/src/VNC/VNCService.swift`:
- Around line 72-109: The code builds vnc URL strings by interpolating raw
password into "vnc://:\(password)@..." which breaks if password contains
reserved userinfo characters; update start(port:password:virtualMachine:) to
percent-encode or validate the password before using it in URLs (keep
self.vncPassword as the original for auth). Replace manual string interpolation
with URLComponents (scheme "vnc", host "127.0.0.1"/hostIP, port
Int(assignedPort)) and set the user/password via URLComponents.user and
URLComponents.password, or explicitly percent-encode the password using
addingPercentEncoding(withAllowedCharacters: .urlUserAllowed) and use that
encoded value when constructing the local and external URL strings; if encoding
fails, throw a VMError (e.g., vncInvalidPassword) to avoid emitting malformed
URLs.

---

Nitpick comments:
In `@libs/lume/tests/Mocks/MockVNCService.swift`:
- Around line 32-35: The mock start(port:password:virtualMachine:) currently
ignores the password and always returns "vnc://localhost:\(port)", so tests skip
the VM.run() path that reads vncInfo.components.password and the auto-assigned
port; update MockVNCService.start to populate url with both the password (when
non-nil) and the effective port (simulate auto-assigned port if port==0 by
picking a non-zero port), e.g. emit a vnc URL that includes the password
component and the chosen port, and leave isRunning and _attachedVM behavior
unchanged so VM.run() can read components.password and the assigned port from
the mock vncInfo.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 181f9ee0-c133-41b6-b1c8-2b695b403e4d

📥 Commits

Reviewing files that changed from the base of the PR and between 91aa142 and 286d109.

📒 Files selected for processing (8)
  • libs/lume/scripts/setup-cua.sh
  • libs/lume/src/Commands/Run.swift
  • libs/lume/src/LumeController.swift
  • libs/lume/src/VM/VM.swift
  • libs/lume/src/VNC/VNCService.swift
  • libs/lume/tests/Mocks/MockVNCService.swift
  • libs/python/computer-server/computer_server/cli.py
  • libs/python/computer-server/computer_server/handlers/vnc.py

Comment on lines +419 to +425
# Wait up to 10s for vnc.env to appear (written by host after VNC starts)
for i in $(seq 1 10); do
if [ -f "$LUME_CONFIG" ]; then
eval "$(cat "$LUME_CONFIG")"
LUME_VNC_PORT="${VNC_PORT:-}"
LUME_VNC_PASSWORD="${VNC_PASSWORD:-}"
echo "Read VNC config from lume shared dir: port=$LUME_VNC_PORT" >> "$LOG_FILE"

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.

⚠️ Potential issue | 🔴 Critical

Avoid eval and raw template injection for VNC secrets.

Line 422 executes the contents of vnc.env, and Lines 458 and 493-494 inject the same password into shell/XML without escaping. A password containing $, backticks, /, &, <, or > will either execute code or produce a broken startup script/plist.

Also applies to: 455-458, 489-494

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@libs/lume/scripts/setup-cua.sh` around lines 419 - 425, The script currently
uses eval on the contents of LUME_CONFIG and later injects LUME_VNC_PASSWORD
unescaped into shell and XML, which allows code execution or malformed output;
replace eval "$(cat "$LUME_CONFIG")" with a safe parser that reads LUME_CONFIG
line-by-line (e.g., while IFS='=' read -r key val; do case "$key" in VNC_PORT)
LUME_VNC_PORT="$val";; VNC_PASSWORD) LUME_VNC_PASSWORD="$val";; esac; done <
"$LUME_CONFIG" so values are not executed, preserve characters by using read -r
and no word-splitting, and when writing the password into shell scripts or
plist/XML use proper escaping (e.g., use printf '%s' "$LUME_VNC_PASSWORD" into
files and replace &,<,>,",\' with XML entities or write the value inside a
single-quoted shell heredoc or use xmllint/PlistBuddy when available) so special
characters like $,`,/,&,<,> are not interpreted or break the output; update
every injection site where LUME_VNC_PASSWORD or LUME_VNC_PORT is inserted
(references: LUME_CONFIG, LUME_VNC_PASSWORD, LUME_VNC_PORT, LOG_FILE) to use the
safe read and escaping approach.

Comment thread libs/lume/src/VM/VM.swift
Comment on lines +218 to +228
// Create a lume-config shared directory so the guest can discover
// the VNC port/password at boot. The directory is created empty now
// and populated after the VNC server starts (VirtioFS exposes live
// host directory contents, so the guest will see the file once written).
let lumeConfigDir = FileManager.default.temporaryDirectory
.appendingPathComponent("lume-config-\(vmDirContext.name)")
try? FileManager.default.createDirectory(at: lumeConfigDir, withIntermediateDirectories: true)
let lumeConfigSharedDir = SharedDirectory(
hostPath: lumeConfigDir.path, tag: "lume-config", readOnly: true)
var allSharedDirectories = sharedDirectories
allSharedDirectories.append(lumeConfigSharedDir)

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.

⚠️ Potential issue | 🟠 Major

Reusing the same lume-config path can serve stale vnc.env.

The host path is deterministic per VM name and never cleared. On a restart, the guest wait loop can see the previous run's vnc.env immediately and keep the old port/password if this run has not rewritten it yet—or if startup fails before rewrite, the stale file persists indefinitely.

Suggested fix
-            let lumeConfigDir = FileManager.default.temporaryDirectory
-                .appendingPathComponent("lume-config-\(vmDirContext.name)")
-            try? FileManager.default.createDirectory(at: lumeConfigDir, withIntermediateDirectories: true)
+            let lumeConfigDir = FileManager.default.temporaryDirectory
+                .appendingPathComponent("lume-config-\(vmDirContext.name)-\(UUID().uuidString)")
+            try FileManager.default.createDirectory(at: lumeConfigDir, withIntermediateDirectories: true)

Apply the same change to the USB-storage path, and clean the directory up on stop.

Also applies to: 263-274, 930-937, 954-962

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@libs/lume/src/VM/VM.swift` around lines 218 - 228, The deterministic
lume-config host path (lumeConfigDir) can serve stale vnc.env across runs;
change creation to use a unique per-run directory (e.g., append a UUID or
run-timestamp to "lume-config-\(vmDirContext.name)") and mirror this change for
the USB-storage path creation code, and ensure the directory is removed during
VM shutdown/stop (implement cleanup in the VM stop/teardown routine so the
lumeConfigDir and the USB-storage temp dir are deleted and not reused). Use the
same unique-name pattern where SharedDirectory is constructed
(lumeConfigSharedDir and the USB shared directory) and add removal logic in the
VM stop/teardown function to delete those temporary host directories.

Comment on lines +123 to +125
# Only override env vars from CLI args when explicitly provided
if args.vnc_port != 5900 or "CUA_VNC_PORT" not in os.environ:
os.environ["CUA_VNC_PORT"] = str(args.vnc_port)

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.

⚠️ Potential issue | 🟠 Major

--vnc-port 5900 still can't override an inherited env value.

argparse always yields 5900 here, so Line 124 cannot distinguish “option omitted” from “user explicitly passed --vnc-port 5900”. If CUA_VNC_PORT is already set by the guest, an explicit CLI override back to 5900 is silently ignored, and the log at Line 129 can report the wrong effective port.

Suggested fix
-    parser.add_argument(
-        "--vnc-port",
-        type=int,
-        default=5900,
-        help="VNC server port (default: 5900)",
-    )
+    parser.add_argument(
+        "--vnc-port",
+        type=int,
+        default=None,
+        help="VNC server port (default: 5900)",
+    )
...
-        if args.vnc_port != 5900 or "CUA_VNC_PORT" not in os.environ:
-            os.environ["CUA_VNC_PORT"] = str(args.vnc_port)
+        if args.vnc_port is not None:
+            os.environ["CUA_VNC_PORT"] = str(args.vnc_port)
+        elif "CUA_VNC_PORT" not in os.environ:
+            os.environ["CUA_VNC_PORT"] = "5900"
...
-        logger.info(f"VNC backend enabled → {vnc_host}:{args.vnc_port}")
+        logger.info(f"VNC backend enabled → {vnc_host}:{os.environ['CUA_VNC_PORT']}")
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@libs/python/computer-server/computer_server/cli.py` around lines 123 - 125,
The current check using "if args.vnc_port != 5900 or 'CUA_VNC_PORT' not in
os.environ" cannot tell if the user explicitly passed --vnc-port 5900; change
the CLI to make args.vnc_port default to None (or argparse.SUPPRESS) and then
set the env only when the arg is provided: use "if args.vnc_port is not None:
os.environ['CUA_VNC_PORT'] = str(args.vnc_port)". Update the
argparse.add_argument call that defines vnc_port and the conditional around
os.environ["CUA_VNC_PORT"] accordingly.

Comment on lines +117 to +156
def _work():
factory = VNCDoToolFactory()
factory.password = self._password or None

@defer.inlineCallbacks
def _do():
try:
reactor.connectTCP(self._host, self._port, factory)
client = yield factory.deferred
res = yield defer.maybeDeferred(fn, client)
result_holder[0] = res
except Exception as e:
error_holder[0] = e
finally:
done_event.set()

_do()

# Ensure the reactor is running in a background thread
if not reactor.running:
t = threading.Thread(
target=reactor.run,
kwargs={"installSignalHandlers": False},
daemon=True,
)
client.timeout = 30
self._client = client
logger.info("VNC connected")
return client
t.start()

reactor.callFromThread(_work)
done_event.wait(timeout=30)
if not done_event.is_set():
raise TimeoutError("VNC operation timed out")
if error_holder[0] is not None:
raise error_holder[0]
return result_holder[0]

def disconnect(self):
with self._lock:
if self._client is not None:
try:
self._client.disconnect()
except Exception:
pass
self._client = None
pass # No persistent connection to close

def _reset_on_error(self):
"""Disconnect so next call reconnects."""
try:
self.disconnect()
except Exception:
pass
pass # No persistent connection to reset

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.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cat -n libs/python/computer-server/computer_server/handlers/vnc.py

Repository: trycua/cua

Length of output: 24741


Close VNC transport in finally block to prevent connection leaks.

_with_client() creates a new TCP session for every operation but never closes client.transport. The finally block only sets the done event, leaving the connection open indefinitely. This contradicts the docstring claim of a "connect, execute, disconnect" lifecycle and causes accumulation of live VNC sessions over multiple operations.

Suggested fix
         `@defer.inlineCallbacks`
         def _do():
+            client = None
             try:
                 reactor.connectTCP(self._host, self._port, factory)
                 client = yield factory.deferred
                 res = yield defer.maybeDeferred(fn, client)
                 result_holder[0] = res
             except Exception as e:
                 error_holder[0] = e
             finally:
+                if client is not None and getattr(client, "transport", None) is not None:
+                    client.transport.loseConnection()
                 done_event.set()
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@libs/python/computer-server/computer_server/handlers/vnc.py` around lines 117
- 156, The _with_client() flow never closes the VNC client transport, leaking
TCP sessions; update the inner _do() in _with_client to always close the
connection in its finally block: after obtaining client = yield factory.deferred
and after the work (or on exception), check if client is not None and has a
transport and call client.transport.loseConnection() (or client.close() if the
client exposes that) to ensure the TCP session is closed; keep the
done_event.set() but move/extend the finally to close the transport so
connections are cleaned up on both success and error (references: _with_client,
inner _do, factory.deferred, client.transport).

Comment on lines +135 to +145
# Ensure the reactor is running in a background thread
if not reactor.running:
t = threading.Thread(
target=reactor.run,
kwargs={"installSignalHandlers": False},
daemon=True,
)
client.timeout = 30
self._client = client
logger.info("VNC connected")
return client
t.start()

reactor.callFromThread(_work)
done_event.wait(timeout=30)

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.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

cat -n libs/python/computer-server/computer_server/handlers/vnc.py | head -200

Repository: trycua/cua

Length of output: 7872


🏁 Script executed:

# Search for the _with_client function and surrounding context
rg -n "_with_client|reactor" libs/python/computer-server/computer_server/handlers/vnc.py -B 5 -A 15

Repository: trycua/cua

Length of output: 10734


🏁 Script executed:

# Check for any threading synchronization (locks, events, etc.) in the file
rg -n "Lock|Condition|Event|RLock|Semaphore|threading" libs/python/computer-server/computer_server/handlers/vnc.py

Repository: trycua/cua

Length of output: 163


🌐 Web query:

Twisted reactor.running multiple threads concurrent start behavior

💡 Result:

Twisted's reactor is a singleton and must run in a single thread. Calling reactor.run (which internally calls startRunning) concurrently from multiple threads is not supported and will lead to undefined behavior, such as hangs, crashes, or corrupted data, because most Twisted APIs are not thread-safe except for specific methods like callFromThread. Key points: - The reactor is designed to run in one "reactor thread" where reactor.run executes the event loop. - reactor.run can only be called once per process; subsequent calls are prevented by internal state flags (_started, running). - From other threads, use reactor.callFromThread or reactor.callInThread to safely interact with the reactor. - Running reactor.run in a background thread (e.g., Thread(target=reactor.run, args=(False,)).start) is possible with some reactors but requires careful coordination; all other Twisted calls must then use thread-safe APIs from that thread, and non-reactor threads must use callFromThread. - Concurrent calls to reactor.run or startRunning from multiple threads simultaneously will race on shared state without locks, causing failures. To handle concurrency, use the reactor's thread pool (callInThread/deferToThread) for blocking work or integrate with other event loops via threaded reactors.

Citations:


🌐 Web query:

Twisted can only have one running reactor instance

💡 Result:

Yes, Twisted can only have one running reactor instance per process. The reactor acts as a global singleton: importing twisted.internet.reactor provides a reference to the single instance, which is automatically installed if none exists. Attempting to install a different reactor after one is already present raises ReactorAlreadyInstalledError. Similarly, calling reactor.run multiple times in the same process raises ReactorAlreadyRunning. Official documentation explicitly states that multiple reactors are not currently possible, though code should prefer passing the reactor explicitly for future compatibility. For multiple reactors, separate processes are required.

Citations:


Reactor bootstrap is still racy under concurrent first use.

The if not reactor.running check is unsynchronized. Two requests hitting _with_client() at the same time can both spawn reactor.run() before the first thread flips that flag. Twisted only supports a single running reactor instance; concurrent calls to reactor.run() from multiple threads cause undefined behavior (hangs, crashes, or data corruption).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@libs/python/computer-server/computer_server/handlers/vnc.py` around lines 135
- 145, The reactor startup must be serialized to avoid concurrent reactor.run()
calls: add a module-level threading.Lock (e.g., reactor_start_lock) and a
threading.Event (e.g., reactor_started_event), then in _with_client() wrap the
"if not reactor.running" check and reactor thread spawn inside
reactor_start_lock to ensure only one thread starts the reactor; after starting
the thread set reactor_started_event from the reactor thread (or wait-loop until
reactor.running is true) and have callers wait on
reactor_started_event.wait(timeout=...) before calling
reactor.callFromThread(_work) and done_event.wait(...). Reference: reactor,
reactor.run, _with_client(), done_event, threading.Thread; ensure you release
the lock quickly and use the event to avoid races where reactor.running is not
yet true immediately after starting the thread.

Comment on lines +176 to +210
self._with_client(lambda c: c.mouseMove(x, y))
self._cursor_x = x
self._cursor_y = y

def mouse_click(self, x: int, y: int, button: int = 1, clicks: int = 1):
client = self._ensure_connected()
client.mouseMove(x, y)
from twisted.internet import defer

@defer.inlineCallbacks
def _do(client):
yield client.mouseMove(x, y)
for _ in range(clicks):
yield client.mousePress(button)
self._with_client(_do)
self._cursor_x = x
self._cursor_y = y
for _ in range(clicks):
client.mousePress(button)

def mouse_down(self, x: int, y: int, button: int = 1):
client = self._ensure_connected()
client.mouseMove(x, y)
from twisted.internet import defer

@defer.inlineCallbacks
def _do(client):
yield client.mouseMove(x, y)
yield client.mouseDown(button)
self._with_client(_do)
self._cursor_x = x
self._cursor_y = y
client.mouseDown(button)

def mouse_up(self, x: int, y: int, button: int = 1):
client = self._ensure_connected()
client.mouseMove(x, y)
from twisted.internet import defer

@defer.inlineCallbacks
def _do(client):
yield client.mouseMove(x, y)
yield client.mouseUp(button)
self._with_client(_do)

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.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let's check the file structure and understand the VNC handler
wc -l libs/python/computer-server/computer_server/handlers/vnc.py

Repository: trycua/cua

Length of output: 117


🏁 Script executed:

# Let's look at the methods in question and the _with_client implementation
sed -n '170,220p' libs/python/computer-server/computer_server/handlers/vnc.py

Repository: trycua/cua

Length of output: 1635


🏁 Script executed:

# Look at the drag_to method and lines 437-455
sed -n '430,460p' libs/python/computer-server/computer_server/handlers/vnc.py

Repository: trycua/cua

Length of output: 1218


🏁 Script executed:

# Let's find the _with_client implementation
grep -n "_with_client" libs/python/computer-server/computer_server/handlers/vnc.py | head -20

Repository: trycua/cua

Length of output: 841


🏁 Script executed:

# Look for the class definition and _with_client method
sed -n '1,100p' libs/python/computer-server/computer_server/handlers/vnc.py

Repository: trycua/cua

Length of output: 3175


🏁 Script executed:

# Look at _with_client implementation
sed -n '102,125p' libs/python/computer-server/computer_server/handlers/vnc.py

Repository: trycua/cua

Length of output: 888


🏁 Script executed:

# Find and examine drag_to method
grep -n "def drag_to" libs/python/computer-server/computer_server/handlers/vnc.py

Repository: trycua/cua

Length of output: 158


🏁 Script executed:

# Look at drag_to implementation
sed -n '213,245p' libs/python/computer-server/computer_server/handlers/vnc.py

Repository: trycua/cua

Length of output: 1270


🏁 Script executed:

# Get the methods around lines 437-455
sed -n '430,460p' libs/python/computer-server/computer_server/handlers/vnc.py

Repository: trycua/cua

Length of output: 1218


🏁 Script executed:

# Let's see the full drag_to and mouse_drag implementations
sed -n '213,280p' libs/python/computer-server/computer_server/handlers/vnc.py

Repository: trycua/cua

Length of output: 2667


🏁 Script executed:

# Let's also see the full _with_client implementation to confirm connection behavior
sed -n '102,150p' libs/python/computer-server/computer_server/handlers/vnc.py

Repository: trycua/cua

Length of output: 1738


🏁 Script executed:

# Verify the connection cleanup behavior - check if connections are explicitly closed
sed -n '102,160p' libs/python/computer-server/computer_server/handlers/vnc.py

Repository: trycua/cua

Length of output: 2009


🏁 Script executed:

# Check if there's any state persistence between _with_client calls
rg "class _VNCConnection" -A 30 libs/python/computer-server/computer_server/handlers/vnc.py

Repository: trycua/cua

Length of output: 1262


Low-level mouse down/up calls do not preserve button state across separate VNC connections.

Each call to mouse_down(), mouse_up(), and move_cursor() creates a fresh VNC connection via _with_client(). This breaks button state preservation for sequences like mouse_down → move_cursor → mouse_up because the button press from the first connection is lost when mouse_up runs on a different connection. The drag_to(), mouse_drag(), and drag_path() methods work around this by executing all operations in a single connection, but the public low-level API remains broken for multi-call flows.

- vnc.py: close client.transport in finally block of _with_client() to
  prevent TCP connection leaks accumulating over multiple operations
- VM.swift: remove old vnc.env before starting VM so the guest can't
  read stale port/password from a previous run
@github-actions

Copy link
Copy Markdown
Contributor

📦 Publishable packages changed

  • lume — will auto-release on merge
  • pypi/computer-server — will auto-release on merge

@github-actions

Copy link
Copy Markdown
Contributor

📦 Publishable packages changed

  • lume — will auto-release on merge
  • pypi/computer-server — will auto-release on merge

@f-trycua
f-trycua merged commit f73a68b into main Mar 23, 2026
20 of 21 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

release:lume Release lume on merge release:pypi/computer-server Release pypi/computer-server on merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant