Implement an asyncio-based chat server
Company: Discord
Role: Software Engineer
Category: Coding & Algorithms
Difficulty: medium
Interview Round: Onsite
## 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.
```hint 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 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 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.
```
#### 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.
```hint 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 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.
```
#### 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.
### What a Strong Answer Covers
Across both parts, the interviewer is looking for:
- An idiomatic, single-event-loop asyncio design (no threads, no blocking I/O on the loop) with one supervised task per connection.
- Backpressure awareness: calling `await writer.drain()` so a fast sender or many recipients can't blow up memory, and a stated policy for slow consumers.
- Resource-leak-free lifecycle: every connection path (normal quit, EOF, reset, exception) removes the client from all shared state and closes the transport.
- Clear, testable structure: small functions (handle connection, broadcast, dispatch command) and a story for how you'd manually test it (e.g. multiple `nc`/telnet sessions).
### 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)?
Overview: 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.
Read the full Discord Software Engineer interview experience this question came from