fix: use cross-platform filelock instead of POSIX-only fcntl (#53)

fcntl is unavailable on Windows, so _LockedFileStore crashed on import there. Switch to the filelock library's ReadWriteLock, preserving the LOCK_SH/LOCK_EX distinction (shared reads via read_lock, exclusive writes via write_lock). Construct the lock with is_singleton=False so two StateStore instances for the same path in one process don't collapse into a single reentrant lock, whose reentrancy guard would otherwise raise RuntimeError under multi-threaded write contention. Add regression tests for cross-process exclusion, concurrent readers, and multi-thread writes, and document the locking design in docs/01_session_management.md.

Tyler committed Jun 10, 2026 at 12:07 UTC 65a3d0248b540dffbbe2aa6638e886dfb7e7d385
5 files changed +247 -11
docs/01_session_management.md
+16
@@ -1,3 +1,8 @@
1 +---
2 +log:
3 +2026-06-10: Replaced the POSIX-only `fcntl.flock` file locking in `_LockedFileStore` with the cross-platform `filelock` library (reported broken on Windows). Reads use `ReadWriteLock.read_lock()` (shared) and writes use `write_lock()` (exclusive), preserving the original `LOCK_SH`/`LOCK_EX` semantics. The lock is constructed with `is_singleton=False` so two `StateStore` instances for the same path in one process don't collapse into a single reentrant lock (which would raise `RuntimeError` on multi-threaded write contention). Added shared-read, cross-process exclusion, and multi-thread/multi-process regression tests.
4 +---
5 +
6 # Design: Session Management (`new`, `status`, `stop`, `sessions`)
7
8 ## Overview
@@ -88,6 +93,12 @@ To prevent Colab VMs from being deleted due to idle timeouts (standard is ~90 mi
93 - Use `requests` for robust HTTP interactions and `pydantic` for schema validation.
94 - Handle authentication headers (likely `Authorization: Bearer <token>` or cookies).
95
96 +### State Persistence & File Locking
97 +- **Stores**: `StateStore` (`sessions.json`) and `SettingsStore` (`settings.json`) both derive from `_LockedFileStore`, which guards concurrent access across independent `colab` invocations and the detached keep-alive daemon.
98 +- **Cross-platform locking**: Locking uses the [`filelock`](https://pypi.org/project/filelock/) library rather than `fcntl.flock`. `fcntl` is POSIX-only and is unavailable on Windows, so the original implementation crashed on import there. `filelock` provides the same advisory cross-process locking on Linux, macOS, and Windows.
99 +- **Shared vs. exclusive**: Each store owns a `filelock.ReadWriteLock` bound to a sidecar file (`<path>.lock`). Reads acquire `read_lock()` (shared — multiple concurrent readers allowed) and writes acquire `write_lock()` (exclusive). This preserves the `LOCK_SH`/`LOCK_EX` distinction of the previous `fcntl` implementation.
100 +- **`is_singleton=False`**: The `ReadWriteLock` is created with `is_singleton=False`. With `filelock`'s default (`True`), two `ReadWriteLock` objects for the same path *within a single process* are deduplicated into one reentrant lock; its reentrancy guard then raises `RuntimeError` when two threads each construct their own `StateStore` and contend for the write lock. Disabling the singleton registry makes each store's lock independent so they serialize via the underlying file lock instead.
101 +
102 ## Testing Strategy
103 TDD is mandatory for all session management features.
104
@@ -100,3 +111,8 @@ TDD is mandatory for all session management features.
111 ### 2. State Store Validation
112 - **Test Case**: Verify `StateStore` correctly handles file locking and multiple concurrent reads/writes.
113 - **Test Case**: Verify `--config` override correctly directs all operations to the specified file path.
114 +- **Test Case (cross-platform locking)**: Verify the store locks via `filelock.ReadWriteLock` on the `<path>.lock` sidecar and does not import the POSIX-only `fcntl`.
115 +- **Test Case (shared/exclusive semantics)**: Verify reads go through `read_lock()` and writes through `write_lock()`.
116 +- **Test Case (cross-process exclusion)**: Hold the write lock from a separate process and confirm the store's in-process write blocks until release.
117 +- **Test Case (concurrent readers)**: Hold a read lock from a separate process and confirm the store can still complete a read concurrently.
118 +- **Test Case (multi-thread regression)**: Two `StateStore` instances writing from different threads must serialize without raising `RuntimeError` (guards the `is_singleton=False` choice).
pyproject.toml
+1
@@ -24,6 +24,7 @@ classifiers = [
24 ]
25 dependencies = [
26 "click>=8.0",
27 + "filelock>=3.29.2",
28 "google-auth>=2.49.1",
29 "google-auth-oauthlib>=1.3.0",
30 "jupyter-kernel-client",
src/colab_cli/state.py
+15 -11
@@ -15,9 +15,10 @@
15 import contextlib
16 import json
17 import os
18 -import fcntl
18 from datetime import datetime
19 from typing import Dict, Optional, Tuple, Iterator, IO
20 +
21 +import filelock
22 from pydantic import BaseModel
23
24
@@ -46,6 +47,15 @@ class Settings(BaseModel):
47 class _LockedFileStore:
48 def __init__(self, path: str):
49 self.path = path
50 + self.lock_path = "%s.lock" % self.path
51 + # ReadWriteLock gives us shared (concurrent) readers and exclusive
52 + # writers -- the cross-platform equivalent of fcntl LOCK_SH/LOCK_EX.
53 + # is_singleton=False keeps each store's lock independent: with the
54 + # default (True), two StateStore instances for the same path in one
55 + # process are merged into a single reentrant lock, whose reentrancy
56 + # guard then raises RuntimeError when two threads contend for the write
57 + # lock. We want them to actually serialize via the underlying file lock.
58 + self._rwlock = filelock.ReadWriteLock(self.lock_path, is_singleton=False)
59 self._ensure_dir()
60
61 def _ensure_dir(self):
@@ -63,21 +73,15 @@ class _LockedFileStore:
73 if not os.path.exists(self.path):
74 yield None
75 return
66 - with open(self.path, "r") as f:
67 - fcntl.flock(f, fcntl.LOCK_SH)
68 - try:
76 + with self._rwlock.read_lock():
77 + with open(self.path, "r") as f:
78 yield f
70 - finally:
71 - fcntl.flock(f, fcntl.LOCK_UN)
79
80 @contextlib.contextmanager
81 def _lock_exclusive(self) -> Iterator[IO]:
75 - with open(self.path, "a+") as f:
76 - fcntl.flock(f, fcntl.LOCK_EX)
77 - try:
82 + with self._rwlock.write_lock():
83 + with open(self.path, "a+") as f:
84 yield f
79 - finally:
80 - fcntl.flock(f, fcntl.LOCK_UN)
85
86
87 class SettingsStore(_LockedFileStore):
tests/test_state.py
+204
@@ -12,11 +12,15 @@
12 # See the License for the specific language governing permissions and
13 # limitations under the License.
14
15 +import multiprocessing
16 import os
17 import pytest
18 import tempfile
19 import threading
20 from datetime import datetime
21 +
22 +import filelock
23 +
24 from colab_cli.state import StateStore, SessionState, SettingsStore, Settings
25
26
@@ -121,3 +125,203 @@ def test_settings_store_save_load(temp_config):
125 loaded = SettingsStore(temp_config).load()
126 assert loaded.enable_update_check is False
127 assert loaded.last_check == datetime(2026, 1, 1)
128 +
129 +
130 +# --- Cross-platform locking (filelock ReadWriteLock) behavior ---------------
131 +
132 +
133 +def test_lock_path_is_derived_from_path(temp_config):
134 + """The lock sidecar file lives next to the data file with a .lock suffix."""
135 + store = StateStore(temp_config)
136 + assert store.lock_path == temp_config + ".lock"
137 +
138 +
139 +def test_store_uses_readwrite_lock_on_sidecar(temp_config):
140 + """The store holds a ReadWriteLock bound to the sidecar path."""
141 + store = StateStore(temp_config)
142 + assert isinstance(store._rwlock, filelock.ReadWriteLock)
143 + assert store._rwlock.lock_file == temp_config + ".lock"
144 +
145 +
146 +def test_separate_store_instances_can_write_from_threads(temp_config):
147 + """Two StateStore instances writing from different threads must not crash.
148 +
149 + Regression guard for filelock's is_singleton=True default: it merges two
150 + same-path ReadWriteLock objects into one reentrant lock whose reentrancy
151 + guard raises RuntimeError when two threads contend for the write lock. We
152 + pass is_singleton=False so they serialize via the underlying file lock.
153 + """
154 + errors = []
155 +
156 + def writer(start):
157 + store = StateStore(temp_config)
158 + try:
159 + for i in range(start, start + 25):
160 + store.add(SessionState(name=f"n{i}", token="t", url="u", endpoint="e"))
161 + except Exception as exc: # pragma: no cover - failure path
162 + errors.append(repr(exc))
163 +
164 + threads = [threading.Thread(target=writer, args=(s,)) for s in (0, 100)]
165 + for t in threads:
166 + t.start()
167 + for t in threads:
168 + t.join()
169 +
170 + assert errors == []
171 + assert len(StateStore(temp_config).list()) == 50
172 +
173 +
174 +def test_write_acquires_write_lock(temp_config, mocker):
175 + """Writes go through the ReadWriteLock's exclusive write_lock()."""
176 + store = StateStore(temp_config)
177 + spy = mocker.spy(store._rwlock, "write_lock")
178 + store.add(SessionState(name="s", token="t", url="u", endpoint="e"))
179 + assert spy.call_count >= 1
180 + assert store.get("s") is not None
181 +
182 +
183 +def test_read_acquires_read_lock(temp_config, mocker):
184 + """Reads go through the ReadWriteLock's shared read_lock()."""
185 + store = StateStore(temp_config)
186 + store.add(SessionState(name="s", token="t", url="u", endpoint="e"))
187 +
188 + spy = mocker.spy(store._rwlock, "read_lock")
189 + assert store.get("s") is not None
190 + assert spy.call_count >= 1
191 +
192 +
193 +def test_uses_filelock_not_fcntl(temp_config):
194 + """The store must lock via the platform-independent filelock library.
195 +
196 + Guards against a regression back to the POSIX-only fcntl.flock approach
197 + that broke Windows users.
198 + """
199 + store = StateStore(temp_config)
200 + assert store._rwlock.__class__.__module__.startswith("filelock")
201 +
202 +
203 +def test_no_fcntl_import_in_state_module():
204 + """The state module must not depend on the POSIX-only fcntl module."""
205 + import colab_cli.state as state_module
206 +
207 + assert not hasattr(state_module, "fcntl")
208 +
209 +
210 +def _mp_hold_write_lock(lock_path, hold_for, acquired_evt, release_evt):
211 + rw = filelock.ReadWriteLock(lock_path)
212 + with rw.write_lock():
213 + acquired_evt.set()
214 + release_evt.wait(timeout=hold_for)
215 +
216 +
217 +def test_write_lock_blocks_across_processes(temp_config):
218 + """A write lock held by another process must block the store's write section.
219 +
220 + The cross-process guarantee is the whole point of the fcntl->filelock
221 + switch, so we hold the write lock from a separate process and confirm the
222 + in-process writer cannot proceed until it's released.
223 + """
224 + store = StateStore(temp_config)
225 + # Pre-create the data file so add() doesn't race on first creation.
226 + store.add(SessionState(name="seed", token="t", url="u", endpoint="e"))
227 +
228 + ctx = multiprocessing.get_context("spawn")
229 + acquired = ctx.Event()
230 + release = ctx.Event()
231 + holder = ctx.Process(
232 + target=_mp_hold_write_lock, args=(store.lock_path, 10, acquired, release)
233 + )
234 + holder.start()
235 + try:
236 + assert acquired.wait(timeout=5), "holder process never acquired the lock"
237 +
238 + finished = threading.Event()
239 +
240 + def writer():
241 + store.add(SessionState(name="blocked", token="t", url="u", endpoint="e"))
242 + finished.set()
243 +
244 + t = threading.Thread(target=writer)
245 + t.start()
246 +
247 + # While the external process holds the write lock, the writer can't finish.
248 + assert not finished.wait(timeout=0.75)
249 +
250 + release.set()
251 + assert finished.wait(timeout=5)
252 + t.join()
253 + assert store.get("blocked") is not None
254 + finally:
255 + release.set()
256 + holder.join(timeout=5)
257 +
258 +
259 +def _mp_hold_read_lock(lock_path, acquired_evt, release_evt):
260 + rw = filelock.ReadWriteLock(lock_path)
261 + with rw.read_lock():
262 + acquired_evt.set()
263 + release_evt.wait(timeout=10)
264 +
265 +
266 +def test_readers_are_concurrent_across_processes(temp_config):
267 + """Shared read locks must allow concurrent readers (the LOCK_SH semantics).
268 +
269 + This is the capability ReadWriteLock buys us over a plain exclusive
270 + FileLock: while one process holds a read lock, this process can still read.
271 + """
272 + store = StateStore(temp_config)
273 + store.add(SessionState(name="s", token="t", url="u", endpoint="e"))
274 +
275 + ctx = multiprocessing.get_context("spawn")
276 + acquired = ctx.Event()
277 + release = ctx.Event()
278 + holder = ctx.Process(
279 + target=_mp_hold_read_lock, args=(store.lock_path, acquired, release)
280 + )
281 + holder.start()
282 + try:
283 + assert acquired.wait(timeout=5), "holder process never acquired the read lock"
284 +
285 + done = threading.Event()
286 + result = {}
287 +
288 + def reader():
289 + result["value"] = store.get("s")
290 + done.set()
291 +
292 + t = threading.Thread(target=reader)
293 + t.start()
294 +
295 + # The read must complete even though another process holds a read lock.
296 + assert done.wait(timeout=3), "concurrent read was blocked by another reader"
297 + t.join()
298 + assert result["value"] is not None
299 + finally:
300 + release.set()
301 + holder.join(timeout=5)
302 +
303 +
304 +def _mp_add_sessions(path, start, count):
305 + store = StateStore(path)
306 + for i in range(start, start + count):
307 + store.add(SessionState(name=f"p{i}", token="t", url="u", endpoint="e"))
308 +
309 +
310 +def test_state_store_multiprocess_concurrency(temp_config):
311 + """filelock must serialize writes across separate processes (not just threads).
312 +
313 + fcntl.flock is per-open-file-description and advisory; this test exercises
314 + the cross-process guarantee that motivated the switch.
315 + """
316 + ctx = multiprocessing.get_context("spawn")
317 + p1 = ctx.Process(target=_mp_add_sessions, args=(temp_config, 0, 40))
318 + p2 = ctx.Process(target=_mp_add_sessions, args=(temp_config, 40, 40))
319 +
320 + p1.start()
321 + p2.start()
322 + p1.join()
323 + p2.join()
324 +
325 + assert p1.exitcode == 0
326 + assert p2.exitcode == 0
327 + assert len(StateStore(temp_config).list()) == 80
uv.lock
+11
@@ -276,6 +276,15 @@ wheels = [
276 { url = "https://files.pythonhosted.org/packages/cb/a8/20d0723294217e47de6d9e2e40fd4a9d2f7c4b6ef974babd482a59743694/fastjsonschema-2.21.2-py3-none-any.whl", hash = "sha256:1c797122d0a86c5cace2e54bf4e819c36223b552017172f32c5c024a6b77e463", size = 24024, upload-time = "2025-08-14T18:49:34.776Z" },
277 ]
278
279 +[[package]]
280 +name = "filelock"
281 +version = "3.29.2"
282 +source = { registry = "https://pypi.org/simple" }
283 +sdist = { url = "https://files.pythonhosted.org/packages/ec/fa/b7dc8a63243aafe57bf53611bd26fa3e0dee2d613590656d44e0a59b7828/filelock-3.29.2.tar.gz", hash = "sha256:779d2f5443b584750c6b90457abffd49235bfb0e66ce82ef5a680867e518ca1c", size = 61449, upload-time = "2026-06-10T14:45:23.072Z" }
284 +wheels = [
285 + { url = "https://files.pythonhosted.org/packages/45/c1/91b0faf8de6938ae72da425e0c6f322d270c044e5ffc372e6f0f89ed1564/filelock-3.29.2-py3-none-any.whl", hash = "sha256:f5d3feb44b2b8824832587543af5226822fe86baf086678ede47aa177fe47ca5", size = 42129, upload-time = "2026-06-10T14:45:21.896Z" },
286 +]
287 +
288 [[package]]
289 name = "google-auth"
290 version = "2.49.1"
@@ -307,6 +316,7 @@ name = "google-colab-cli"
316 source = { editable = "." }
317 dependencies = [
318 { name = "click" },
319 + { name = "filelock" },
320 { name = "google-auth" },
321 { name = "google-auth-oauthlib" },
322 { name = "jupyter-kernel-client" },
@@ -333,6 +343,7 @@ dev = [
343 [package.metadata]
344 requires-dist = [
345 { name = "click", specifier = ">=8.0" },
346 + { name = "filelock", specifier = ">=3.29.2" },
347 { name = "google-auth", specifier = ">=2.49.1" },
348 { name = "google-auth-oauthlib", specifier = ">=1.3.0" },
349 { name = "jupyter-kernel-client", git = "https://github.com/googlecolab/jupyter-kernel-client.git" },