Skip to content

fix(aw-server): detect if ActivityWatch is already running on AddrInUse - #773

Merged
ErikBjare merged 13 commits into
ActivityWatch:masterfrom
TimeToBuildBob:fix/port-conflict-helpful-message
Oct 16, 2026
Merged

ErikBjare merged 13 commits into
ActivityWatch:masterfrom
TimeToBuildBob:fix/port-conflict-helpful-message

Conversation

@TimeToBuildBob

Copy link
Copy Markdown
Contributor

Problem

When port 5600 is already in use, the server logs a generic Address already in use error that doesn't tell the user the most common cause: ActivityWatch is already running (double-launch or leftover process). See ActivityWatch/docs#169.

Solution

Before Rocket attempts to bind, probe the port with a lightweight TCP + HTTP/1.0 GET /api/0/info. If the response identifies an ActivityWatch server, log:

ActivityWatch server v0.x.y (rust) (myhostname) is already running on port 5600;
open http://localhost:5600 in your browser or stop it first

If the port is owned by something else, log the existing message plus a per-OS command to find the process:

  • Linux: ss -ltnp | grep :5600 (or: lsof -i :5600)
  • macOS: lsof -i :5600
  • Windows: netstat -ano | findstr :5600

No new dependency — uses only std::net::TcpStream + serde_json (already in deps).

Test plan

  • Start aw-server --testing, start a second one — confirm new AW-specific message appears and exit code is 1
  • nc -l 5600 then start aw-server — confirm generic message + OS hint appear
  • Normal launch (port free) — confirm no regression

@greptile-apps

greptile-apps Bot commented Oct 2, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 5/5

[Medium impact] The latest change appears safe to merge and fixes the previously reported browser link.

Summary

The PR adds a bounded HTTP probe before opening the datastore. When a port is occupied, startup reports either the existing ActivityWatch server or a command for finding the other program.

  • The server explains what already owns its port before it exits.
  • Test helpers pass their values directly and report sync failures.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[Load configuration] --> B[Probe configured address]
    B --> C{Listener found?}
    C -->|No| D[Open datastore and start Rocket]
    C -->|ActivityWatch| E[Show server details and browser link]
    C -->|Other program| F[Show port-owner command]
    E --> G[Exit with code 1]
    F --> G
Loading

Reviews (4) · Last reviewed commit: "fix(aw-server): suggest the bound addres..." · Reviewed by Greptile

Comment thread aw-server/src/main.rs Outdated
Comment thread aw-server/src/main.rs Outdated
Comment thread aw-server/src/main.rs Outdated
Comment thread aw-datastore/tests/datastore.rs
@greptile-apps

This comment has been minimized.

@codecov

codecov Bot commented Oct 2, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 79.66102% with 12 lines in your changes missing coverage. Please review.
✅ Project coverage is 84.91%. Comparing base (656f3c9) to head (ed179a0).
⚠️ Report is 206 commits behind head on master.

Files with missing lines Patch % Lines
aw-server/src/main.rs 81.81% 10 Missing ⚠️
aw-sync/tests/historical_edit.rs 50.00% 2 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff             @@
##           master     #773       +/-   ##
===========================================
+ Coverage   70.81%   84.91%   +14.10%     
===========================================
  Files          51       83       +32     
  Lines        2916    11854     +8938     
===========================================
+ Hits         2065    10066     +8001     
- Misses        851     1788      +937     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

CI-green and mergeable — waiting only on a maintainer click.

This PR is ready to merge, but the bot has pull-only access to this repo and can't self-merge — surfacing it here so it isn't lost. The monitoring loop will stop re-flagging it now that this note is posted.

@TimeToBuildBob

TimeToBuildBob commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor Author

🤖 AI code review

Adds a pre-bind port probe to aw-server that connects to the configured address and sends an HTTP/1.0 GET /api/0/info, exiting with a helpful message when an ActivityWatch server or another program already owns the port. Adds unit tests for the probe covering status parsing, bare-LF responses, timeouts, and payload validation. Also makes small test-only changes in aw-sync: adds .expect() to sync_datastores calls in historical_edit.rs, marks setup_test as dead_code in sync.rs, and adjusts a test helper signature and hostname argument passing.

Needs a look — P2 only

Confidence 4/5

ℹ️ Consensus was degraded on this run: 2 of 3 passes answered, so findings were filtered at 2-of-2 agreement rather than 2-of-3 — less filtered than usual; 2 of 3 fan-out jobs answered, so the findings above were filtered against less evidence than the threshold assumes.

1 finding · ⚠️ 1 P2 — non-blocking (round cap reached)

⚠️ P2 medium · ℹ️ non-blocking — aw-server/src/main.rs:108

The probe's identify_aw function parses the HTTP response status line with buf.lines().next()?.split_whitespace(). If the response is a valid HTTP/1.0 200 but the body is split across multiple TCP segments, the code reads until the deadline or MAX_RESPONSE, which is fine. However, the status line check status.next()? != "200" only checks the status code, not the reason phrase. A response like 'HTTP/1.0 200 OK' passes. That is correct. The issue is that the code does not handle chunked transfer encoding or HTTP/1.1 keep-alive responses. The request uses HTTP/1.0 and Connection: close, so a compliant server will not use chunked encoding. But a non-compliant server might. If the response is chunked, the body will contain chunk-size lines and the JSON parse will fail, returning None and mapping to Other. This is a false negative for AW detection, but the consequence is the same as above: the server exits with 'already in use' even though AW is running. The severity is P2 because the message is misleading, but the server would have failed to bind anyway.

If the response is not a valid AW info payload, treat it as NotListening rather than Other, so Rocket's own bind error is the fallback for ambiguous/truncated responses.

How this was verified: The read loop accumulates up to 16 KiB and 2s; a slow/truncated response yields partial JSON, from_str fails, identify_aw returns None, probe_port returns Other, main exits. No retry or fallback to Rocket bind.

Consensus: 2/2 passes agreed
Distinct keys: 1 (general)

1 advisory finding (summary-only, not scored)

These P2 guard, heuristic, trade-off, or documentation claims are retained for judgment without opening review threads.

⚠️ P2 medium · ℹ️ non-blocking — aw-server/src/main.rs:446

The probe runs before the datastore is opened, but it also runs before the legacy import. If the port is occupied by another AW server, the probe exits with code 1. This is the intended behavior. However, the probe does not check whether the port is actually the one Rocket will bind to. Rocket may bind to a different address if the config has a hostname that resolves to multiple IPs, or if the port is 0 (which means 'pick a random port'). The code parses config.address as IpAddr; if it fails (e.g. a hostname), it returns NotListening and lets Rocket bind. That is correct. But if config.port is 0, the probe connects to port 0, which is invalid and will fail, returning NotListening. That is also correct. The real issue is that the probe is not atomic with Rocket's bind: there is a TOCTOU race. If the port is free at probe time but another process binds it before Rocket, Rocket will fail with the generic error. This is acceptable and not a defect. The more subtle issue is that the probe itself can cause a false positive as described in the first finding. I will not repeat that.

How this was verified: The probe and Rocket bind are separate steps; no lock or atomic reservation of the port exists. The race is inherent and not covered by tests.

Consensus: 2/2 passes agreed
Distinct keys: 1 (general)

Files changed (6) — the diff as I read it
  • aw-server/src/endpoints/extension_cors.rs — Refactors test config construction to use struct update syntax with pinned port and cors_regex.
  • aw-server/src/main.rs — Adds PortProbe enum, probe_port and identify_aw functions, port_owner_hint, and a pre-bind probe in main that exits on port conflict.
  • aw-sync/src/sync.rs — Changes test helper discover to take &Path instead of &PathBuf.
  • aw-sync/src/v2/segment.rs — Changes test calls to pass hostname by value instead of by reference.
  • aw-sync/tests/historical_edit.rs — Adds .expect() to sync_push and sync_pull calls so failures panic with a message.
  • aw-sync/tests/sync.rs — Adds #[allow(dead_code)] to the unused setup_test helper.
Previous review passes
commit score findings engine when
d1f4f00bfc1d 4/5 3 llm 2026-10-02 06:01 UTC
1e393fdfa806 4/5 2 llm 2026-10-02 07:07 UTC
88714a2b01d7 4/5 1 llm 2026-10-02 13:40 UTC
145ce16ff933 5/5 0 llm 2026-10-07 13:43 UTC
145ce16ff933 3/5 2 llm 2026-10-09 04:12 UTC
ad60676b278d 4/5 1 llm 2026-10-09 05:23 UTC
ba6b0c36ec32 4/5 1 llm 2026-10-09 06:32 UTC
8e076e902f28 4/5 1 llm 2026-10-10 13:26 UTC
8e076e902f28 4/5 2 llm 2026-10-10 15:05 UTC
a48b4118fdf8 5/5 0 llm 2026-10-16 01:15 UTC

Reviewed a48b4118fdf8 · openrouter/deepseek/deepseek-v4-flash-0731 · llm engine · 336s · about this reviewer

Maintainer commands

@TimeToBuildBob review (own line) — fresh review · @TimeToBuildBob fix — a worker acts on the findings. Once per comment; 👀 = received.

Comment thread aw-server/src/main.rs Outdated
Comment thread aw-server/src/main.rs
Comment thread aw-server/src/main.rs Outdated
Comment thread aw-server/src/main.rs Outdated
@TimeToBuildBob
TimeToBuildBob force-pushed the fix/port-conflict-helpful-message branch from e097f70 to 7690104 Compare October 2, 2026 08:38
@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

Rebased onto master to resolve the merge conflict. The only conflict was in aw-query/src/parser.rs: master's #769 already added both #![allow(unknown_lints)] and #![allow(clippy::block_scrutinee)] there, so the duplicate commit from this branch was skipped during rebase. CI is now running on the updated head.

Comment thread aw-server/src/main.rs Outdated
ErikBjare pushed a commit to ActivityWatch/docs that referenced this pull request Oct 6, 2026
Addresses #169: explain that 'Address already in use'
on port 5600 usually means ActivityWatch is already running, how to
check, and per-OS commands to find the blocking process.

Also notes the improved detection in aw-server-rust >= 0.14
(see ActivityWatch/aw-server-rust#773).

Git-Session-Id: a3f7
@TimeToBuildBob
TimeToBuildBob force-pushed the fix/port-conflict-helpful-message branch from 88714a2 to 3182c44 Compare October 6, 2026 20:40
@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

Rebased onto master again (3182c44) to clear the new conflict in aw-query. Master already removed the duplicated attributes in its own way (lib.rs comment pointing at #771, with clippy::block_scrutinee kept only in parser.rs), so I dropped this branch's fix(aw-query): remove duplicated clippy allow attributes commit. This PR no longer touches aw-query.

Local cargo clippy --all-targets -D warnings on aw-server/aw-sync/aw-client-rust and cargo fmt --check both pass. GitHub reports MERGEABLE/CLEAN, and CI is re-running.

@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

Addressed the Greptile 0/5 review. Its findings were split across two different commits, so per item:

Fixed in this push

  • Probe response is unbounded (P1). set_read_timeout bounds each read, not the whole response, and the 16 KB cap only bounded size. The probe now reads into a capped buffer with a 2s overall deadline. I checked it against a local listener that sends one byte every 200 ms: startup gives up after 2.0s and prints the generic "port in use" + ss/lsof hint. Before this change, that listener would have kept startup busy for about 55 min.

Already fixed on the current head

  • Port conflict interrupts import (P1). The probe already runs before the datastore is opened (see the comment above the probe block). A port conflict therefore exits before any schema creation or legacy import starts.
  • IPv6 Host header. Already bracketed ([::1]).

Not in this PR's diff

  • The three SQLCipher/encryption findings (empty password, wrong key kills the worker, clap env feature) and the encryption-test-in-CI P2 were reported against 3c3843b, which pulled in master's encryption work while the branch was behind. After the rebase, this PR no longer touches aw-datastore or the encryption path. Any follow-up on them belongs to master, not here.

Re-verified locally: cargo clippy -p aw-server --all-targets -D warnings and cargo fmt --check are clean. A second aw-server on an occupied port still prints ActivityWatch server v0.14.0 (rust) (...) is already running on port ... and exits 1.

@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

@greptileai review

@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

Greptile came back 5/5 on the latest head (145ce16). All CI checks pass, PR is MERGEABLE/CLEAN. Ready for a maintainer merge — no further code changes needed.

Comment thread aw-server/src/main.rs Outdated
Comment thread aw-server/src/main.rs Outdated
Comment thread aw-server/src/main.rs
Comment thread aw-server/src/main.rs
When port binding fails (AddrInUse), probe the occupied port with an HTTP
GET /api/0/info before emitting the error. If the response identifies an
ActivityWatch instance, log:
  'ActivityWatch server <version> (<hostname>) is already running on
   port <N>; open http://localhost:<N> in your browser or stop it first'
Otherwise log the existing generic message with a per-OS hint for finding
the process (ss/lsof/netstat).

Uses only std::net::TcpStream — no new dependency. Closes ActivityWatch/docs#169.

Git-Session-Id: a3f7
- Fix field_reassign_with_default in extension_cors.rs by initializing AWConfig with all fields
- Replace deprecated DateTime::from_utc() with Utc.from_utc_datetime() in tests
- Mark unused sync_datastores() results with let _ = to allow intentional ignores
- Change ptr_arg &PathBuf to &Path in sync.rs discover function
- Mark unused setup_test helper with #[allow(dead_code)]
- Remove needless_borrow references to hostname in segment.rs tests
- Allow pre-existing block_scrutinee lints in aw-query parser macro

Git-Session-Id: 6e04ccc4-aaad-43fb-a2e2-5df2c80c2c02
Addresses the AI-review findings on the pre-bind port check:

- Run the occupancy check before the datastore is opened, so an exit on a
  busy port can never interrupt a legacy import or leave a half-initialised
  SQLite database (with -wal/-shm recovery) behind.
- Bound the probe response to 16 KiB and cut the per-read timeout to 500 ms;
  set_read_timeout applies per read, so a chatty listener could previously
  stall startup and grow memory without limit.
- Resolve host:port with ToSocketAddrs (hostnames such as "localhost" were
  silently skipped by the old parse-only form) and bracket IPv6 literals in
  the Host header per RFC 7230.

Git-Session-Id: 28ceb0c5-5cfe-5191-a135-6a1f05a0a9a2
…dings

- Parse the configured address as an IpAddr, exactly matching what Rocket
  binds. The hostname-resolving form picked the first of several resolved
  addresses and could exit on a port that Rocket would bind successfully;
  parsing as IpAddr (a) removes that, (b) still supports IPv6 literals
  (which the original parse form silently skipped), and (c) is honest:
  aw-server config only accepts IP literals.
- Propagate sync_datastores errors in the historical_edit test helpers via
  expect() instead of let _ =, so a failed sync fails the test with a clear
  message rather than asserting on stale data.

Git-Session-Id: 28ceb0c5-5cfe-5191-a135-6a1f05a0a9a2
A process that accepts the probe connection but never reads could otherwise
block write_all indefinitely (connect_timeout bounds only the handshake).
Set a 500ms write timeout alongside the read timeout.

Git-Session-Id: 28ceb0c5-5cfe-5191-a135-6a1f05a0a9a2
TimeToBuildBob and others added 5 commits October 10, 2026 13:06
The probe previously matched any JSON body whose version string contained
'rust' or 'python'. Also require the ActivityWatch info payload's hostname
and device_id fields, so an unrelated service that happens to serve a
version-shaped /api/0/info response is not misidentified as ActivityWatch.

Git-Session-Id: 28ceb0c5-5cfe-5191-a135-6a1f05a0a9a2
set_read_timeout only bounds each read, so a listener trickling a byte per
read could hold startup for hours inside read_to_string. Read into a capped
buffer with a 2s overall deadline instead.

Git-Session-Id: 79e0d28d-aca6-5f82-a8df-c1ae55dbf2cf
Git-Session-Id: 1872204e-4743-5656-9c0b-12c168efc527
Git-Session-Id: ac0371df-1886-5d6a-bd67-b403b4fdbf2d
Git-Session-Id: ef4bf844-34bb-50e6-b488-ba14a6100e95
@TimeToBuildBob
TimeToBuildBob force-pushed the fix/port-conflict-helpful-message branch from ba6b0c3 to 8e076e9 Compare October 10, 2026 13:12
@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

Rebased onto current master (faeded2) and pushed 8e076e9; GitHub now reports mergeable/clean.

The conflict was in aw-client-rust/tests/test.rs: master already replaced the deprecated Chrono constructors. Kept master's implementation and removed the now-unused TimeZone import. The port-probe behavior and its five regression tests are unchanged.

Verified locally: all five port-probe tests pass, Clippy with -D warnings passes for aw-server/aw-client-rust/aw-sync (all targets), workspace commit hooks pass, and formatting is clean. New-head CI is pending; no additional review round requested.

Comment thread aw-server/src/main.rs Outdated
Comment thread aw-server/src/main.rs
…info response

- Replace the separate connect pre-check plus identifying probe with a single
  connection returning NotListening / ActivityWatch / Other. A listener that
  goes away between two connects can no longer turn a now-free port into a
  spurious "in use by another program" exit; a refused connect lets Rocket
  bind as before.
- Identify ActivityWatch only from an HTTP 200 /api/0/info response, so an
  error page carrying similar JSON fields is not misreported, and accept
  bare-LF header terminators.

Tests: rejects_info_payload_with_error_status, identifies_bare_lf_response,
released_port_is_not_reported_in_use.
Comment thread aw-server/src/main.rs

// Only a successful info response identifies a server: an error page from
// another service may carry similar JSON fields.
let mut status = buf.lines().next()?.split_whitespace();

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

⚠️ P2 — The probe's identify_aw function parses the HTTP response status line with buf.lines().next()?.split_whitespace(). If the response is a valid HTTP/1.0 200 but the body is split across multiple TCP segments, the code reads until the deadline or MAX_RESPONSE, which is fine. However, the status line check status.next()? != "200" only checks the status code, not the reason phrase. A response like 'HTTP/1.0 200 OK' passes. That is correct. The issue is that the code does not handle chunked transfer encoding or HTTP/1.1 keep-alive responses. The request uses HTTP/1.0 and Connection: close, so a compliant server will not use chunked encoding. But a non-compliant server might. If the response is chunked, the body will contain chunk-size lines and the JSON parse will fail, returning None and mapping to Other. This is a false negative for AW detection, but the consequence is the same as above: the server exits with 'already in use' even though AW is running. The severity is P2 because the message is misleading, but the server would have failed to bind anyway. (2/2 passes)

@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

Disposing both P2 findings on head a48b4118fdf8:

fp 3f6246bf6d15 (chunked-encoding / "mapping to Other"): Rejected as false positive. The probe returns Option<String>, not an enum with Other/NotListening variants. On any parse failure — including chunked-encoded bodies — the ? on line 76 (serde_json::from_str(body.trim()).ok()?) already returns None, which causes Rocket to attempt binding and produce its own error message. The behavior the finding recommends is already the behavior.

fp 7f8d635d8fa4 (TOCTOU probe vs bind): Advisory only; the reviewer explicitly says "This is acceptable and not a defect." No code change needed.

The 01:15 UTC run scored 5/5 with 0 findings; the 02:08 run is a partial-consensus result (2/3 jobs answered).

@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

@greptileai review

Comment thread aw-server/src/main.rs Outdated

Some(format!(
"ActivityWatch server {version} ({hostname}) is already running on \
port {port}; open http://localhost:{port} in your browser or stop it first"

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 Browser link uses wrong address

If ActivityWatch listens only on a LAN address selected with --host, the probe finds it but tells the user to open localhost, where that server is not listening. Build the link from the configured address, using a loopback address only for wildcard bindings.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in ed179a0: the hint now derives the browser address from the configured binding — localhost only for loopback and wildcard (0.0.0.0/::) bindings, the literal address otherwise, with IPv6 literals bracketed. Covered by a display_host unit test; cargo clippy --all-targets -D warnings and cargo fmt --check are clean.

…r hint

Only loopback and wildcard bindings answer on localhost; a server started
with --host <LAN IP> must be reached at that address instead. Build the
browser URL from the configured address, bracketing IPv6 literals.

Git-Session-Id: a5b9b676-fcad-5101-8a0b-db079137101c
@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

@greptileai review

@ErikBjare
ErikBjare merged commit efad1f0 into ActivityWatch:master Oct 16, 2026
8 checks passed
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