Katkat

Implement an asyncio-based chat server

DiscordSoftware Engineer · Onsite

Last updated

This question tests practical knowledge of Python's asyncio library and concurrent programming by requiring the implementation of a TCP chat server. It evaluates a software engineer's ability to manage multiple simultaneous connections, handle resource cleanup, and apply event-driven concurrency patterns — core competencies commonly assessed in backend and systems-focused interviews.

Async Chat Server (Python asyncio)

Build a TCP chat server using Python's asyncio. The server listens on a given port and serves many concurrent clients over a simple line-based text protocol. This is a phone-screen problem: the interviewer cares about correct concurrency, clean resource handling, and clear communication far more than features.

You will build the server in two parts — start with a single global room (Part 1), then add room and command support (Part 2).

Constraints & Assumptions

  • Up to ~1,000 concurrent connections.
  • Standard library only (asyncio, socket, etc.) — no third-party packages.
  • Line-based UTF-8 text protocol; one logical message per \n-terminated line.
  • No persistence required (in-memory state is fine; state may be lost on restart).
  • Correctness and clear concurrency handling are valued over advanced features (no auth, no TLS, no message history needed unless asked).

Clarifying Questions to Ask

  • What framing should broadcast messages use — exactly <username>: <message>, and should server notices (joins, leaves, command output) be visually distinguishable from chat?
  • Are usernames required to be unique? What should happen if a chosen name is already taken?
  • How should the server behave when a slow client can't keep up — drop the message, buffer it, or disconnect the client?
  • Should an unknown command (e.g. /foo) be an error, or treated as a normal chat message?
  • Is there a maximum line length we should enforce to bound memory?
  • Should /quit send a goodbye, and should peers be notified when someone disconnects?

Part 1 — Single global room

Implement a server where every connected client shares one global room.

Protocol (line-based text):

  • On connect, the server prompts for a username (the client's first line is taken as the username).
  • After login, each subsequent line is a chat message.
  • The server broadcasts each message to all other connected clients in the format <username>: <message>.

Behavior requirements:

  • A client must not receive its own message echoed back.
  • Disconnects must be handled gracefully — no crashes, and the client's resources are cleaned up.
  • One misbehaving client (e.g. disconnecting mid-line, sending malformed bytes) must not take down the server or affect other clients.

What This Part Should Cover

  • Correct use of the asyncio streams API (start_server, StreamReader/StreamWriter, readline, drain) with one task per client.
  • A shared client registry and a broadcast that excludes the sender, with awareness of why locking is/isn't needed in single-threaded asyncio.
  • Robust lifecycle handling: graceful EOF/exception handling and guaranteed cleanup in a finally block so a single bad client can't crash the server.

Part 2 — Rooms and chat commands

Extend the protocol so a line beginning with / is interpreted as a command; all other lines remain chat messages.

  • /join <room> — join (creating it if needed) a named room. Each user is in exactly one room at a time; joining a new room leaves the previous one. After joining, broadcasts go only to users in the same room.
  • /who — reply (to the requester only) with the list of usernames currently in the user's room.
  • /quit — disconnect the client cleanly.

Clarifying Questions for this Part

  • Should there be a default room a user lands in before their first /join, or are they "lobby-less" until they join?
  • When a user switches rooms, should the old room and the new room receive join/leave notices?

What This Part Should Cover

  • A room data model that keeps the per-client → room mapping and the room → members mapping consistent on join/leave/disconnect (no stale membership, empty rooms cleaned up).
  • A clean command parser that distinguishes commands from messages, scopes broadcasts to a single room, and routes /who/errors back to only the requester.
  • Correct handling of /quit and disconnects so a leaving user is removed from exactly one room's membership.

Hints

Hint 1

Where to start: Reach for the high-level streams API: `asyncio.start_server(handle_client, host, port)` gives you a `(reader, writer)` pair per connection and runs your coroutine as one independent task per client. Use `await reader.readline()` to consume one line at a time.

Hint 2

Shared state and broadcast: Keep a shared registry of connected clients (e.g. a `dict` mapping a client object/username to its `StreamWriter`). To broadcast, iterate the registry and `writer.write(...)` to everyone except the sender, then `await writer.drain()`. Because asyncio is single-threaded with cooperative scheduling, you don't need locks around the dict as long as you never `await` mid-mutation — but you must add/remove entries carefully.

Hint 3

Cleanup that always runs: Wrap the per-client read loop in `try/except/finally`. `reader.readline()` returns an empty `bytes` (`b""`) on EOF and may raise `ConnectionResetError` / `asyncio.IncompleteReadError`. Do registry removal and `writer.close()` in the `finally` so cleanup happens on every exit path.

Hint 4

Modeling rooms: Replace the single global registry with a room index: `rooms: dict[str, set[Client]]`, plus a back-reference so each client knows its current room. `/join` becomes: remove the client from its old room's set, add to the new room's set, update the back-reference. Garbage-collect a room when its set becomes empty to avoid an unbounded `rooms` dict.

Hint 5

Command dispatch: Parse only the first token: split the line once (`line.split(maxsplit=1)`) and branch on the leading `/command`. Keep broadcast scoped to `rooms[client.room]` rather than all clients. `/who` and command errors should write back only to the requesting `writer`, not broadcast.

Follow-up questions

  • How would you make this horizontally scalable across multiple server processes/machines so users in the same room but on different servers still see each other (e.g. a pub/sub backbone)?
  • How would you handle a slow or stuck client whose socket buffer is full without blocking everyone else — what backpressure or timeout strategy would you use?
  • How would you add graceful shutdown (drain in-flight writes, notify clients) on `SIGTERM`?
  • How would you bound memory and prevent abuse (max line length, max rooms/users, rate limiting)?