repro_zombie_session.py 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238
  1. #!/usr/bin/env python3
  2. """
  3. Reproduces a zombie session bug in the TOC server.
  4. The bug: there is a race window between RegisterBOSSession (which creates a
  5. session and makes it visible to concurrent logins) and the OnSessionClose
  6. callback registration. If a second login evicts the session during this window,
  7. the default no-op OnSessionClose runs, RemoveSession is never called, the
  8. session slot's `removed` channel is never closed, and the user is permanently
  9. locked out until server restart.
  10. Prerequisites:
  11. - Server running on 127.0.0.1:9898 (TOC) and 127.0.0.1:8080 (API)
  12. - User "toctest1" exists (create via: curl -X POST
  13. http://127.0.0.1:8080/user -d '{"screen_name":"toctest1","password":"testpass1"}')
  14. Usage:
  15. python3 scripts/repro_zombie_session.py
  16. """
  17. import json
  18. import socket
  19. import struct
  20. import subprocess
  21. import sys
  22. import threading
  23. import time
  24. HOST = "127.0.0.1"
  25. TOC_PORT = 9898
  26. API_PORT = 8080
  27. USER = "toctest1"
  28. PASS = "testpass1"
  29. ROAST = "Tic/Toc"
  30. def roast_password(password):
  31. result = []
  32. for i, ch in enumerate(password):
  33. xored = ord(ch) ^ ord(ROAST[i % len(ROAST)])
  34. result.append(f"{xored:02x}")
  35. return "0x" + "".join(result)
  36. def send_flap(sock, frame_type, seq, payload):
  37. data = payload.encode("ascii") if isinstance(payload, str) else payload
  38. if frame_type == 2:
  39. data += b"\x00"
  40. header = struct.pack("!BBHH", 0x2A, frame_type, seq, len(data))
  41. sock.sendall(header + data)
  42. return seq + 1
  43. def recv_flap(sock, timeout=5):
  44. sock.settimeout(timeout)
  45. header = b""
  46. while len(header) < 6:
  47. chunk = sock.recv(6 - len(header))
  48. if not chunk:
  49. raise ConnectionError("connection closed")
  50. header += chunk
  51. _, frame_type, seq, length = struct.unpack("!BBHH", header)
  52. payload = b""
  53. while len(payload) < length:
  54. chunk = sock.recv(length - len(payload))
  55. if not chunk:
  56. raise ConnectionError("connection closed")
  57. payload += chunk
  58. return frame_type, seq, payload
  59. def recv_all(sock, timeout=2):
  60. msgs = []
  61. while True:
  62. try:
  63. ft, _, payload = recv_flap(sock, timeout=timeout)
  64. if ft == 2:
  65. msgs.append(payload.decode("ascii", errors="replace").rstrip("\x00"))
  66. except (socket.timeout, ConnectionError, OSError):
  67. break
  68. return msgs
  69. def toc_login(user, pw):
  70. sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
  71. sock.connect((HOST, TOC_PORT))
  72. sock.settimeout(5)
  73. sock.sendall(b"FLAPON\r\n\r\n")
  74. recv_flap(sock)
  75. sn = user.lower().replace(" ", "").encode("ascii")
  76. signon_payload = struct.pack("!IHH", 1, 1, len(sn)) + sn
  77. seq = send_flap(sock, 1, 0, signon_payload)
  78. roasted = roast_password(pw)
  79. cmd = f'toc_signon login.oscar.aol.com 5190 {user} {roasted} english "TIC:Test"'
  80. seq = send_flap(sock, 2, seq, cmd)
  81. msgs = recv_all(sock, timeout=2)
  82. return sock, seq, msgs
  83. def check_sessions():
  84. r = subprocess.run(
  85. ["curl", "-s", f"http://{HOST}:{API_PORT}/session"],
  86. capture_output=True,
  87. text=True,
  88. )
  89. return json.loads(r.stdout)
  90. def main():
  91. print("=" * 60)
  92. print("Zombie Session Reproduction Script")
  93. print("=" * 60)
  94. print()
  95. # Verify server is reachable
  96. try:
  97. s = socket.create_connection((HOST, TOC_PORT), timeout=2)
  98. s.close()
  99. except OSError:
  100. print(f"ERROR: Cannot connect to TOC server at {HOST}:{TOC_PORT}")
  101. print("Start the server first: make run")
  102. sys.exit(1)
  103. # Verify user exists
  104. sessions = check_sessions()
  105. print(f"Server reachable. Active sessions: {sessions['count']}")
  106. print()
  107. # Step 1: Establish a fully-initialized session
  108. print("[Step 1] Login as toctest1, fully initialize session...")
  109. sock_a, seq_a, msgs_a = toc_login(USER, PASS)
  110. sign_on = any(m.startswith("SIGN_ON:") for m in msgs_a)
  111. if not sign_on:
  112. print(f" ERROR: Login failed. Response: {msgs_a}")
  113. print(" Make sure user 'toctest1' exists with password 'testpass1'.")
  114. sys.exit(1)
  115. seq_a = send_flap(sock_a, 2, seq_a, "toc_add_buddy toctest2")
  116. seq_a = send_flap(sock_a, 2, seq_a, "toc_init_done")
  117. time.sleep(2)
  118. recv_all(sock_a, timeout=1)
  119. sessions = check_sessions()
  120. print(f" Session A online. Active sessions: {sessions['count']}")
  121. print()
  122. # Step 2: Abruptly close the socket (no SIGNOFF frame)
  123. print("[Step 2] Abruptly closing socket (no SIGNOFF frame)...")
  124. sock_a.close()
  125. print(" Socket closed.")
  126. print()
  127. # Step 3: Immediately launch two parallel logins
  128. print("[Step 3] Launching two parallel logins for the same user...")
  129. print(" (Login B and Login C race to evict the stale session)")
  130. results = {}
  131. def login_worker(name):
  132. try:
  133. sock, seq, msgs = toc_login(USER, PASS)
  134. results[name] = ("ok", msgs)
  135. sock.close()
  136. except Exception as e:
  137. results[name] = ("error", str(e))
  138. t_b = threading.Thread(target=login_worker, args=("B",))
  139. t_c = threading.Thread(target=login_worker, args=("C",))
  140. t_b.start()
  141. t_c.start()
  142. t_b.join(timeout=15)
  143. t_c.join(timeout=15)
  144. for name in sorted(results):
  145. status, data = results[name]
  146. if status == "error":
  147. print(f" Login {name}: ERROR - {data}")
  148. else:
  149. got_sign_on = any(m.startswith("SIGN_ON:") for m in data)
  150. print(f" Login {name}: {'SIGN_ON' if got_sign_on else 'empty/failed'} - {[m[:50] for m in data]}")
  151. print()
  152. # Step 4: Wait for any cleanup, then verify the zombie
  153. print("[Step 4] Waiting 3 seconds for any pending cleanup...")
  154. time.sleep(3)
  155. sessions = check_sessions()
  156. print(f" Active sessions: {sessions['count']}")
  157. print()
  158. # Step 5: Try a fresh login — if the bug was triggered, this will fail
  159. print("[Step 5] Attempting a fresh login (should succeed if no zombie)...")
  160. try:
  161. sock, seq, msgs = toc_login(USER, PASS)
  162. sign_on = any(m.startswith("SIGN_ON:") for m in msgs)
  163. if sign_on:
  164. print(f" Login succeeded: {[m[:50] for m in msgs]}")
  165. print()
  166. print(" RESULT: Bug NOT triggered this run (race was won by the")
  167. print(" correct ordering). Re-run the script to try again.")
  168. sock.close()
  169. else:
  170. print(f" Login returned empty/error: {msgs}")
  171. print()
  172. # Confirm it's permanent
  173. print("[Step 6] Waiting 10 seconds and trying once more...")
  174. time.sleep(10)
  175. sock2, _, msgs2 = toc_login(USER, PASS)
  176. sign_on2 = any(m.startswith("SIGN_ON:") for m in msgs2)
  177. if sign_on2:
  178. print(f" Second attempt succeeded (cleanup was slow but finished)")
  179. sock2.close()
  180. else:
  181. print(f" Second attempt also failed: {msgs2}")
  182. print()
  183. print(" *** ZOMBIE SESSION CONFIRMED ***")
  184. print()
  185. print(" The user 'toctest1' is permanently locked out.")
  186. print(" The /session endpoint shows 0 sessions, but AddSession")
  187. print(" keeps finding a stale session slot whose `removed`")
  188. print(" channel will never close.")
  189. print()
  190. print(" Root cause: Login C evicted Login B's session before B")
  191. print(" registered its OnSessionClose callback. The default")
  192. print(" no-op ran, RemoveSession was never called, and the")
  193. print(" session slot is stuck forever.")
  194. print()
  195. print(" Only a server restart will fix this.")
  196. sock2.close()
  197. except Exception as e:
  198. print(f" Login attempt failed with exception: {e}")
  199. print()
  200. print(" *** ZOMBIE SESSION CONFIRMED ***")
  201. print()
  202. print("Check the server log for [DEBUG-RACE] lines (if using the")
  203. print("instrumented build) or 'context deadline exceeded' errors.")
  204. if __name__ == "__main__":
  205. main()