@vibelord/lanes 1.1.1 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.md +36 -5
  2. package/bin/lanes-install.mjs +166 -13
  3. package/lib/plan.mjs +113 -3
  4. package/package.json +1 -1
  5. package/payload/lanes/README.md +45 -22
  6. package/payload/lanes/VERSION +1 -1
  7. package/payload/lanes/bin/lanes +2459 -164
  8. package/payload/lanes/client/ghostty/lanes-palette-light.conf +19 -19
  9. package/payload/lanes/client/ghostty/lanes-palette.conf +19 -19
  10. package/payload/lanes/client/lanes-launch +289 -40
  11. package/payload/lanes/client/lanes-window +15 -3
  12. package/payload/lanes/container/hooks/claude-settings.json +1 -0
  13. package/payload/lanes/container/lane +106 -92
  14. package/payload/lanes/container/lane-edit +100 -0
  15. package/payload/lanes/container/lane-mcp +1091 -0
  16. package/payload/lanes/container/lane-signal +109 -29
  17. package/payload/lanes/container/lane_core.py +1140 -0
  18. package/payload/lanes/container/vimrc +31 -0
  19. package/payload/lanes/docs/ARCHITECTURE.md +618 -13
  20. package/payload/lanes/docs/DESIGN.md +122 -44
  21. package/payload/lanes/docs/INSTALL.md +157 -17
  22. package/payload/lanes/docs/RUNBOOK.md +603 -72
  23. package/payload/lanes/docs/SHARING.md +3 -2
  24. package/payload/lanes/docs/USERGUIDE.md +777 -136
  25. package/payload/lanes/install/enable-web.sh +2 -0
  26. package/payload/lanes/install/host-install.sh +163 -7
  27. package/payload/lanes/install/services.py +148 -18
  28. package/payload/lanes/install.sh +68 -7
  29. package/payload/lanes/libexec/agent_state.py +74 -1
  30. package/payload/lanes/libexec/cursor_sync.py +6 -0
  31. package/payload/lanes/libexec/device_sync.py +47 -10
  32. package/payload/lanes/libexec/economy_poll.py +1 -0
  33. package/payload/lanes/libexec/enrold.py +41 -22
  34. package/payload/lanes/libexec/keys.py +54 -23
  35. package/payload/lanes/libexec/lanes_lib.py +620 -22
  36. package/payload/lanes/libexec/members.py +16 -2
  37. package/payload/lanes/libexec/notify.py +104 -24
  38. package/payload/lanes/libexec/registrations.py +216 -18
  39. package/payload/lanes/libexec/session_actions.py +182 -14
  40. package/payload/lanes/libexec/sidebar +11 -3
  41. package/payload/lanes/libexec/sidebar.py +2135 -361
  42. package/payload/lanes/libexec/statusd.py +137 -10
  43. package/payload/lanes/libexec/studio_host.py +8 -1
  44. package/payload/lanes/libexec/tokens.py +121 -30
  45. package/payload/lanes/libexec/tokens.sh +86 -2
  46. package/payload/lanes/libexec/update.py +972 -0
  47. package/payload/lanes/libexec/users.py +614 -0
  48. package/payload/lanes/libexec/view.sh +34 -6
  49. package/payload/lanes/systemd/lanes-ensure.service +3 -0
  50. package/payload/lanes/systemd/lanes-gc.service +1 -1
  51. package/payload/lanes/systemd/lanes-update.service +17 -0
  52. package/payload/lanes/systemd/lanes-update.timer +17 -0
  53. package/payload/lanes/web/README.md +83 -24
  54. package/payload/lanes/web/browser.js +3 -5
  55. package/payload/lanes/web/mobile.css +17 -17
  56. package/payload/lanes/web/mobile.js +44 -32
  57. package/payload/lanes/workspaces.example/README.md +3 -1
  58. package/payload/lanes/zellij/config.kdl +106 -56
  59. package/payload/manifest.json +5 -3
@@ -11,10 +11,14 @@ import fcntl
11
11
  import hashlib
12
12
  import json
13
13
  import os
14
+ import posixpath
14
15
  import re
16
+ import secrets
15
17
  import shlex
16
18
  import signal
19
+ import stat
17
20
  import subprocess
21
+ import sys
18
22
  import tempfile
19
23
  import time
20
24
  import unicodedata
@@ -23,6 +27,144 @@ from pathlib import Path
23
27
  REPO = Path(__file__).resolve().parent.parent
24
28
  STATE = Path(os.environ.get('LANES_STATE', Path.home() / '.local/state/lanes'))
25
29
  SHARE = Path(os.environ.get('LANES_SHARE', Path.home() / '.local/share/lanes'))
30
+ PACKAGE_STAMP = '.lanes-package.json' # npm/lanes/bin/lanes-install.mjs `STAMP`, written into every tree it stages
31
+ RUN_DIR = 'run' # STATE/run/<name>.tree: the tree each long-running process started from
32
+ CURRENT_LINK = '.local/share/lanes/current' # under the HOME directory, never $XDG_DATA_HOME: install/services.py
33
+
34
+
35
+ def current_link(home=None):
36
+ """Where install/services.py writes the `current` link: `<home>/.local/share/lanes/current` (default: this
37
+ account's home; raises KeyError/RuntimeError with no HOME and no passwd entry). The npm wrapper's prune protects the
38
+ same path (npm/lanes/lib/plan.mjs `currentLink`); `current_tree` and `lanes version` read it."""
39
+ return (Path(home) if home is not None else Path.home()) / CURRENT_LINK
40
+
41
+
42
+ def staged_tree(path):
43
+ """Whether `path` is a tree the npm wrapper staged, not a checkout: it sits in the default pkg root
44
+ (`…/pkg/<version>`), or it carries the wrapper's stamp (PACKAGE_STAMP, a regular file lanes-install.mjs stage()
45
+ writes into every tree it stages — under `$LANES_PKG_ROOT/<version>` too, whatever that directory is called;
46
+ DEF-1757). A checkout has neither."""
47
+ path = Path(path)
48
+ if path.parent.name == 'pkg':
49
+ return True
50
+ try:
51
+ return (path / PACKAGE_STAMP).is_file()
52
+ except OSError: # an unreadable directory says nothing either way: not staged
53
+ return False
54
+
55
+
56
+ def current_links(repo, home=None):
57
+ """Where the `current` link of the staged tree `repo` can be, in the order current_tree and installed_tree look:
58
+ beside the pkg root (`<pkg>/../current`, the default `…/pkg/<version>` layout only), then under the home
59
+ (current_link; left out with no HOME and no passwd entry)."""
60
+ repo = Path(repo)
61
+ links = [repo.parent.parent / 'current'] if repo.parent.name == 'pkg' else []
62
+ try:
63
+ links.append(current_link(home))
64
+ except (KeyError, RuntimeError): # no HOME and no passwd entry: the pkg-root candidate alone
65
+ pass
66
+ return links
67
+
68
+
69
+ def current_tree(repo=None, home=None):
70
+ """The path `repo` is INSTALLED under — the one layouts, units and wrappers should embed (plan W9 U1): the `current`
71
+ link when `repo` is an npm-staged tree (`staged_tree`: `…/pkg/<version>`, or any `$LANES_PKG_ROOT/<version>` with
72
+ the wrapper's stamp) and the link resolves to exactly that tree; else `repo` itself — a checkout install, or a
73
+ staged tree the link no longer names (after an update an old tree must never hand out a link that now means
74
+ another version). REPO is always the RESOLVED tree, because __file__ is resolved through the link.
75
+
76
+ The link is where install/services.py writes it, `~/.local/share/lanes/current` from the home directory, and —
77
+ for the default `…/pkg/<version>` layout only — beside the pkg root (`<pkg>/../current`, the same path unless the
78
+ npm wrapper staged under $XDG_DATA_HOME, or the only candidate with no home). The link stays under the home either
79
+ way (the wrapper's prune protects the same link: npm/lanes/lib/plan.mjs `currentLink`); beside a `$LANES_PKG_ROOT`
80
+ of another name nothing manages a link, so none is looked for there."""
81
+ repo = Path(repo) if repo is not None else REPO
82
+ if not staged_tree(repo):
83
+ return repo
84
+ links = current_links(repo, home)
85
+ try:
86
+ target = repo.resolve()
87
+ except (OSError, RuntimeError):
88
+ return repo
89
+ for link in links:
90
+ try:
91
+ if link.resolve() == target:
92
+ return link
93
+ except (OSError, RuntimeError): # a dangling link or a loop names no tree
94
+ continue
95
+ return repo
96
+
97
+
98
+ CURRENT = current_tree()
99
+
100
+
101
+ def installed_tree(repo=None, home=None):
102
+ """Where a running process re-reads the INSTALLED release (the sidebar's version note and self-swap, cockpit v3 V2
103
+ and U5): for a staged `repo` (default REPO), the first of its current_links that is a link — whatever tree it names
104
+ NOW, unlike current_tree, which collapses to `repo` once an update has flipped the link to another tree (that flip
105
+ is exactly what this reader must see); else `repo` itself: a checkout, or a staged tree no link stands beside, is
106
+ its own installed tree. lstat only: nothing is resolved here."""
107
+ repo = Path(repo) if repo is not None else REPO
108
+ if not staged_tree(repo):
109
+ return repo
110
+ for link in current_links(repo, home):
111
+ try:
112
+ if link.is_symlink():
113
+ return link
114
+ except OSError:
115
+ continue
116
+ return repo
117
+
118
+ # The tree files a generated layout runs (bin/lanes layout_kdl, `lanes doctor` layouts_doctor), as `<tree>/<file>`.
119
+ LAYOUT_FILES = ('libexec/sidebar', 'libexec/view.sh', 'holders/ssh.sh')
120
+
121
+
122
+ def tree_file_at(words, name):
123
+ """The index of the first of a pane command's `words` that runs the tree file `name` (one of LAYOUT_FILES) of ANY
124
+ lanes tree, else None: an absolute path ending in `/<name>`. Since W9 U1 a layout names its files through CURRENT
125
+ (`…/current/<name>`), a layout built before names the `pkg/<version>` tree that built it, a checkout names itself —
126
+ and one session can hold panes of each after an update. They are the same pane, so a reader (device_sync's tab
127
+ identities, bin/lanes focused_view and stale_picker_layouts) matches the suffix, never one tree's full path; a
128
+ relative path, or a file that only ends like it (`/opt/myview.sh`), runs no tree file."""
129
+ suffix = '/' + name
130
+ return next((index for index, word in enumerate(words) if word.startswith('/') and word.endswith(suffix)), None)
131
+
132
+
133
+ def tree_version(path):
134
+ """A tree's release, `<path>/VERSION` stripped; None when the file is absent, unreadable or empty. One open(), no
135
+ stat: the sidebar re-reads the installed tree's every 30 s through it (cockpit v3 V2), and its tick budget counts
136
+ stats."""
137
+ try:
138
+ with open(Path(path) / 'VERSION', encoding='utf-8') as handle:
139
+ text = handle.read().strip()
140
+ except (OSError, ValueError): # ValueError: undecodable bytes
141
+ return None
142
+ return text or None
143
+
144
+
145
+ def tree_stamp(path=None):
146
+ """The npm wrapper's stamp of a staged tree (default REPO), as {version, digest, commit}: each a string, else None.
147
+ None for a checkout (no stamp) and for a stamp that is not a JSON object."""
148
+ document = read_json(Path(path if path is not None else REPO) / PACKAGE_STAMP)
149
+ if not isinstance(document, dict):
150
+ return None
151
+ return {key: document[key] if isinstance(document.get(key), str) else None
152
+ for key in ('version', 'digest', 'commit')}
153
+
154
+
155
+ def record_tree(name, state=None):
156
+ """Write `<state>/run/<name>.tree` (default STATE) = the tree this process runs from — str(REPO), the RESOLVED path,
157
+ and a newline — rewritten at every start, so its mtime is the start time. Each long-running process calls it once
158
+ when it starts (statusd, enrold, economy, notify, the Studio Host enable step, the sidebar); `lanes version` and
159
+ doctor compare the records with `current` (plan W9 V1, V3). -> the path written, or None when the state directory
160
+ cannot be written: a record is observation, never a reason for a daemon or a sidebar not to start."""
161
+ _require(IDENT, name, 'tree record name')
162
+ path = Path(state if state is not None else STATE) / RUN_DIR / f'{name}.tree'
163
+ try:
164
+ atomic_write(path, f'{REPO}\n')
165
+ except OSError:
166
+ return None
167
+ return path
26
168
 
27
169
 
28
170
  def workspaces_dir():
@@ -104,6 +246,20 @@ class LanesError(Exception):
104
246
  pass
105
247
 
106
248
 
249
+ class ProjectionRefused(LanesError):
250
+ """A change a person made (register, rename) was SAVED and published everywhere except these containers' own
251
+ `~/.lz/registry.json` — `publish_registry`'s `[(container, why)]` (DEF-1796 refusals). The action that published
252
+ raises it so the person hears it at once: `lane open` / `lane-signal` in those containers still resolve against
253
+ the old projection, and the next timer sync only reports it to its journal. `result` is what the action returns
254
+ on success (the sidebar finishes its success path with it); `pending` is the sentence after `<what> saved; `."""
255
+
256
+ def __init__(self, what, refused, result=None):
257
+ self.what, self.refused, self.result = what, list(refused), result
258
+ self.pending = 'run lanes sync to finish publishing: ' + '; '.join(
259
+ f'container {container}: {why}' for container, why in self.refused)
260
+ super().__init__(f'{what} saved; {self.pending}')
261
+
262
+
107
263
  def atomic_write(path, text, mode=0o600):
108
264
  """Replace `path` with `text` atomically (temp file in the same directory, chmod, rename). `text` may be bytes:
109
265
  then it is written byte for byte, never through the locale's encoding (keys.py keeps foreign authorized_keys
@@ -138,9 +294,101 @@ def atomic_write_if_changed(path, text, mode=0o600):
138
294
  return True
139
295
 
140
296
 
297
+ # Writing INTO a directory a container owns (LNS-32: statusd's `~/.lz/status.json`, written from the host through
298
+ # /proc/<container init pid>/root; DEF-1796: `lanes sync`'s `~/.lz/registry.json`, through the home volume). The
299
+ # container can plant anything there: an absolute symlink resolves against the HOST's root when the host follows it,
300
+ # and a FIFO blocks a plain open-for-read forever. So the directory is walked with O_NOFOLLOW per component, the
301
+ # compare read never follows a link or blocks, and the temp file is O_EXCL in the same directory, renamed over
302
+ # whatever the name holds (rename replaces a link, it never writes through it).
303
+ _DIR_FLAGS = os.O_RDONLY | os.O_DIRECTORY | getattr(os, 'O_CLOEXEC', 0)
304
+
305
+
306
+ def _leaf(name):
307
+ """`name` when it is one directory entry (no `/`, not `.`/`..`/empty): every *_at helper resolves exactly one."""
308
+ if not isinstance(name, str) or not name or '/' in name or name in ('.', '..') or '\0' in name:
309
+ raise LanesError(f'Invalid path component: {name!r}')
310
+ return name
311
+
312
+
313
+ def open_real_dir(base, parts):
314
+ """fd of `base`/<parts…>, every component in `parts` a REAL directory (a symlink is refused with OSError, never
315
+ followed). `base` itself is trusted and opened as given (it may be the /proc/<pid>/root magic link). The caller
316
+ closes the fd."""
317
+ fd = os.open(str(base), _DIR_FLAGS)
318
+ try:
319
+ for part in parts:
320
+ child = os.open(_leaf(part), _DIR_FLAGS | os.O_NOFOLLOW, dir_fd=fd)
321
+ os.close(fd)
322
+ fd = child
323
+ except BaseException:
324
+ os.close(fd)
325
+ raise
326
+ return fd
327
+
328
+
329
+ def read_regular_at(dir_fd, name, limit):
330
+ """The bytes of `name` in `dir_fd` when it is a regular file of at most `limit` bytes, else None (absent, a
331
+ symlink, a FIFO or device, bigger). Never follows a link, never blocks on a FIFO (O_NONBLOCK on the open)."""
332
+ _leaf(name)
333
+ try:
334
+ fd = os.open(name, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK | getattr(os, 'O_CLOEXEC', 0), dir_fd=dir_fd)
335
+ except OSError:
336
+ return None
337
+ try:
338
+ info = os.fstat(fd)
339
+ if not stat.S_ISREG(info.st_mode) or info.st_size > limit:
340
+ return None
341
+ chunks, size = [], 0
342
+ while size <= limit:
343
+ chunk = os.read(fd, limit + 1 - size)
344
+ if not chunk:
345
+ break
346
+ chunks.append(chunk)
347
+ size += len(chunk)
348
+ return b''.join(chunks) if size <= limit else None
349
+ except OSError:
350
+ return None
351
+ finally:
352
+ os.close(fd)
353
+
354
+
355
+ def atomic_write_if_changed_at(dir_fd, name, text, mode=0o600):
356
+ """`atomic_write_if_changed` inside an open directory (see `open_real_dir`) -> True when it wrote. `text` is str
357
+ (UTF-8) or bytes; the file is replaced only when it is not already a regular file holding exactly these bytes."""
358
+ data = text.encode() if isinstance(text, str) else text
359
+ if read_regular_at(dir_fd, name, len(data)) == data:
360
+ return False
361
+ atomic_write_at(dir_fd, name, data, mode)
362
+ return True
363
+
364
+
365
+ def atomic_write_at(dir_fd, name, text, mode=0o600):
366
+ """`atomic_write` inside an open directory: an O_EXCL|O_NOFOLLOW temp file beside `name`, chmod, then a rename
367
+ that replaces whatever `name` is (a symlink or FIFO planted there is replaced, never followed or opened)."""
368
+ _leaf(name)
369
+ data = text.encode() if isinstance(text, str) else text
370
+ temporary = f'.{name}.{secrets.token_hex(6)}'
371
+ fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW | getattr(os, 'O_CLOEXEC', 0),
372
+ 0o600, dir_fd=dir_fd)
373
+ try:
374
+ with os.fdopen(fd, 'wb') as handle:
375
+ handle.write(data)
376
+ os.fchmod(handle.fileno(), mode)
377
+ os.replace(temporary, name, src_dir_fd=dir_fd, dst_dir_fd=dir_fd)
378
+ except BaseException:
379
+ try:
380
+ os.unlink(temporary, dir_fd=dir_fd)
381
+ except OSError:
382
+ pass
383
+ raise
384
+
385
+
141
386
  def _same_json(path, document, ignore=('generated_at',)):
142
387
  """True when `path` holds `document` minus the `ignore` keys (a timestamp alone is not a change)."""
143
- old = read_json(path)
388
+ return _same_document(read_json(path), document, ignore)
389
+
390
+
391
+ def _same_document(old, document, ignore=('generated_at',)):
144
392
  if not isinstance(old, dict):
145
393
  return False
146
394
  return ({k: v for k, v in old.items() if k not in ignore} ==
@@ -178,6 +426,38 @@ def show_lane(view_dir, workspace, slot, lane_id):
178
426
  return False
179
427
 
180
428
 
429
+ # DM-LANES-SIDEBAR-CMD (cockpit v3 S4): a key handed to a sidebar without typing it. Ctrl-Alt-/ and Ctrl-Alt-u
430
+ # (zellij/config.kdl) walk the focus to the sidebar and run `lanes sidebar-key filter|upload --focused`, which writes
431
+ # {"cmd": <one of SIDEBAR_KEYS>, "at": <epoch seconds>} to <view_dir>/<workspace>.sidebar.cmd; that workspace's sidebar
432
+ # acts on each new file (reload()'s stamp, and before every read of keys). Writer and reader share these helpers.
433
+ SIDEBAR_KEYS = ('filter', 'upload')
434
+ SIDEBAR_KEY_FRESH = 10.0 # seconds: an older command (one a sidebar took before it restarted) is history, not a key
435
+
436
+
437
+ def sidebar_key_path(view_dir, workspace):
438
+ return os.path.join(str(view_dir), f'{workspace}.sidebar.cmd')
439
+
440
+
441
+ def send_sidebar_key(view_dir, workspace, key, now=None):
442
+ """Write one sidebar command atomically (0600). `key` must be one of SIDEBAR_KEYS."""
443
+ if key not in SIDEBAR_KEYS:
444
+ raise LanesError(f'unknown sidebar key {key!r} (one of {", ".join(SIDEBAR_KEYS)})')
445
+ at = time.time() if now is None else now
446
+ atomic_write(sidebar_key_path(view_dir, workspace), json.dumps({'cmd': key, 'at': at}) + '\n')
447
+
448
+
449
+ def read_sidebar_key(path, now=None):
450
+ """The key a sidebar command file holds, or None: unreadable, not the v1 shape, an unknown key, or an `at` more
451
+ than SIDEBAR_KEY_FRESH seconds away from now."""
452
+ document = read_json(path)
453
+ if not isinstance(document, dict):
454
+ return None
455
+ key, at = document.get('cmd'), document.get('at')
456
+ if key not in SIDEBAR_KEYS or isinstance(at, bool) or not isinstance(at, (int, float)):
457
+ return None
458
+ return key if abs((time.time() if now is None else now) - at) <= SIDEBAR_KEY_FRESH else None
459
+
460
+
181
461
  def clean_seen(document):
182
462
  """DM-LANES-CLIENT-SEEN as read: string keys with numeric values only (anything else is ignored)."""
183
463
  return {k: v for k, v in (document.items() if isinstance(document, dict) else ())
@@ -256,6 +536,9 @@ class NoSuchContainer(LanesError):
256
536
  INSPECT_FORMAT = ('{{.Name}}\t{{.State.Pid}}\t{{range .Mounts}}{{if eq .Destination "' + CONTAINER_HOME +
257
537
  '"}}{{.Source}}{{end}}{{end}}')
258
538
  _GONE = re.compile(r'no such (?:object|container): (\S+)', re.I)
539
+ # How long the one `docker inspect` of a sync may take before it counts as no answer. A bound other code sizes its
540
+ # waits by (the sidebar's PENDING_S), so it is named here, where it is enforced.
541
+ DOCKER_INSPECT_S = 20
259
542
 
260
543
 
261
544
  def containers_info(names):
@@ -276,9 +559,10 @@ def containers_info(names):
276
559
  if not names:
277
560
  return {}
278
561
  label = 'docker inspect ' + ' '.join(names)
279
- rc, out, err = run_bounded(['docker', 'inspect', '--type', 'container', '-f', INSPECT_FORMAT, *names], 20)
562
+ rc, out, err = run_bounded(['docker', 'inspect', '--type', 'container', '-f', INSPECT_FORMAT, *names],
563
+ DOCKER_INSPECT_S)
280
564
  if rc is None:
281
- raise LanesError(f'{label}: no answer within 20 s')
565
+ raise LanesError(f'{label}: no answer within {DOCKER_INSPECT_S} s')
282
566
  result = {}
283
567
  for line in out.splitlines():
284
568
  parts = line.split('\t')
@@ -356,6 +640,21 @@ def host_path(info, container_path):
356
640
  return Path(info['home']) / container_path[len(CONTAINER_HOME) + 1:]
357
641
 
358
642
 
643
+ def lane_core():
644
+ """container/lane_core.py — the in-container contract's shared helpers — as a module, one instance per process
645
+ (sys.modules, the same one `bin/lanes` loads for the agents' MCP entries): the host edits a container's
646
+ terminals.json with the very editor lane-mcp runs inside it (session_actions.unregister_declared, LNS-35)."""
647
+ import sys
648
+ module = sys.modules.get('lane_core')
649
+ if module is None:
650
+ import importlib.util
651
+ spec = importlib.util.spec_from_file_location('lane_core', str(REPO / 'container' / 'lane_core.py'))
652
+ module = importlib.util.module_from_spec(spec)
653
+ spec.loader.exec_module(module)
654
+ sys.modules['lane_core'] = module
655
+ return module
656
+
657
+
359
658
  # A markdown path as a lane prints it: not glued to a word or a `/` on its left, `.md` ending at a word boundary
360
659
  # (`foo.md5`, `c.mdx` are not markdown). `lanes preview` (plan Task 15) offers what this finds.
361
660
  MARKDOWN_PATH = re.compile(r'(?<![\w/])(?:\.{0,2}/)?[\w./-]+\.md\b')
@@ -386,6 +685,116 @@ def markdown_candidates(text, cwd):
386
685
  return found
387
686
 
388
687
 
688
+ # ------------------------------------------------------- container file system
689
+ # `lanes edit` (W9 E2, LNS-96) looks at a lane's checkout through /proc/<init>/root (`root`). A symbolic link in there
690
+ # is the container's: walked by the kernel from the host, an ABSOLUTE target would restart at the HOST's root (and
691
+ # os.path.realpath reads /proc/<init>/root itself as `/`), so every path below is resolved one component at a time
692
+ # with the container's root as `/`, and only a path whose every component is no link is opened.
693
+
694
+ CONTROL_CHARS = re.compile(r'[\x00-\x1f\x7f]')
695
+ SYMLINK_LIMIT = 40 # as the kernel's ELOOP bound
696
+ GIT_FILE_LIMIT = 4096 # a .git link file or a commondir file is one short line
697
+
698
+
699
+ def container_realpath(root, path):
700
+ """The absolute container path `path` names, every symbolic link resolved inside the container whose root
701
+ directory is `root` on the host: an absolute target restarts at `root`, `..` climbs from where a link led (the
702
+ kernel's walk, not string folding) and never above `/`. A component that does not exist (or cannot be read) is
703
+ kept as it is, like os.path.realpath(strict=False). Raises LanesError after SYMLINK_LIMIT links, and for a path
704
+ with a NUL byte (no file has one; os.readlink would raise ValueError)."""
705
+ if '\x00' in path:
706
+ raise LanesError('a path with a NUL byte names no file')
707
+ pending = [part for part in path.split('/') if part not in ('', '.')]
708
+ resolved, links = [], 0
709
+ while pending:
710
+ part = pending.pop(0)
711
+ if part == '..':
712
+ if resolved:
713
+ resolved.pop()
714
+ continue
715
+ try:
716
+ target = os.readlink(Path(root, *resolved, part))
717
+ except OSError: # EINVAL: no link; ENOENT, EACCES: kept as it is
718
+ resolved.append(part)
719
+ continue
720
+ links += 1
721
+ if links > SYMLINK_LIMIT:
722
+ raise LanesError('too many symbolic links')
723
+ if target.startswith('/'):
724
+ resolved = []
725
+ pending = [p for p in target.split('/') if p not in ('', '.')] + pending
726
+ return '/' + '/'.join(resolved)
727
+
728
+
729
+ def container_lstat(root, path):
730
+ """os.lstat of a RESOLVED container path (container_realpath) under `root`, or None for any OSError: a caller that
731
+ names the file it asked about never prints the error, which carries the host path (W9 E2: `.env.local`)."""
732
+ try:
733
+ return os.lstat(Path(root, path.lstrip('/')))
734
+ except (OSError, ValueError): # ValueError: a NUL byte
735
+ return None
736
+
737
+
738
+ def _container_line(root, path):
739
+ """The first line of the small regular file at the resolved container path `path`, trailing whitespace removed, or
740
+ None when it is missing, no regular file, a link, or unreadable. Never follows a link and never blocks on a FIFO."""
741
+ try:
742
+ fd = os.open(Path(root, path.lstrip('/')), os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK | os.O_CLOEXEC)
743
+ except (OSError, ValueError):
744
+ return None
745
+ try:
746
+ if not stat.S_ISREG(os.fstat(fd).st_mode):
747
+ return None
748
+ text = os.read(fd, GIT_FILE_LIMIT).decode('utf-8', 'replace')
749
+ except OSError:
750
+ return None
751
+ finally:
752
+ os.close(fd)
753
+ return (text.splitlines() or [''])[0].rstrip()
754
+
755
+
756
+ def _main_checkout(root, gitdir, worktree):
757
+ """The main checkout of a linked worktree whose gitdir is `gitdir`: the folder holding the common `.git` its
758
+ `commondir` names (relative to the gitdir; git writes `../..`). No commondir (a submodule, a --separate-git-dir
759
+ checkout) or a common dir that is not a checkout's `.git` (a bare repository): the worktree is its own. The file is
760
+ the container's to write: one with a control character names no main checkout either."""
761
+ common = _container_line(root, container_realpath(root, posixpath.join(gitdir, 'commondir')))
762
+ if not common or CONTROL_CHARS.search(common):
763
+ return worktree
764
+ common = container_realpath(root, posixpath.join(gitdir, common))
765
+ if posixpath.basename(common) != '.git':
766
+ return worktree
767
+ main = posixpath.dirname(common)
768
+ found = container_lstat(root, main)
769
+ return main if found and stat.S_ISDIR(found.st_mode) else worktree
770
+
771
+
772
+ def repo_roots(cwd, root):
773
+ """(worktree root, main checkout) of the git checkout the container path `cwd` is in, both resolved container paths,
774
+ read through `root` (/proc/<init>/root). Walks up from `cwd` to the first `.git`: a directory holding HEAD makes
775
+ that folder the top level and its own main checkout; a `.git` FILE (`gitdir: <path>`, relative to its folder or
776
+ absolute in the container) makes it a linked worktree whose gitdir's `commondir` leads to the main checkout
777
+ (_main_checkout). Raises LanesError for a cwd that is not an absolute path without control characters, a `.git`
778
+ file without a gitdir line (or with a control character in it), and no checkout up to `/`."""
779
+ if not isinstance(cwd, str) or not cwd.startswith('/') or CONTROL_CHARS.search(cwd):
780
+ raise LanesError('the lane has no usable working directory')
781
+ here = container_realpath(root, cwd)
782
+ while True:
783
+ dotgit = container_realpath(root, posixpath.join(here, '.git'))
784
+ found = container_lstat(root, dotgit)
785
+ if found and stat.S_ISDIR(found.st_mode) and os.path.lexists(Path(root, dotgit.lstrip('/'), 'HEAD')):
786
+ return here, here
787
+ if found and stat.S_ISREG(found.st_mode):
788
+ line = _container_line(root, dotgit) or ''
789
+ if not line.startswith('gitdir: ') or not line[8:] or CONTROL_CHARS.search(line):
790
+ raise LanesError(f'{posixpath.join(here, ".git")} is not a git link file')
791
+ gitdir = container_realpath(root, posixpath.join(here, line[8:]))
792
+ return here, _main_checkout(root, gitdir, here)
793
+ if here == '/':
794
+ raise LanesError(f'no git checkout in or above {cwd}')
795
+ here = posixpath.dirname(here)
796
+
797
+
389
798
  def tmux_socket(info, socket_name):
390
799
  return f"/proc/{info['pid']}/root/tmp/tmux-{CONTAINER_UID}/{socket_name}"
391
800
 
@@ -548,8 +957,11 @@ def read_health(state_dir=None):
548
957
 
549
958
  # ------------------------------------------------------------------ registry
550
959
 
960
+ REGISTRY_LOCK_S = 5 # how long a sync, registration or close waits for the lock before "busy" (sizes PENDING_S)
961
+
962
+
551
963
  @contextlib.contextmanager
552
- def registry_lock(timeout=5):
964
+ def registry_lock(timeout=REGISTRY_LOCK_S):
553
965
  """Serialize sync, registration and close across device sidebars and the timer."""
554
966
  STATE.mkdir(parents=True, exist_ok=True)
555
967
  with (STATE / 'registry.lock').open('a') as handle:
@@ -568,6 +980,93 @@ def registry_lock(timeout=5):
568
980
  fcntl.flock(handle, fcntl.LOCK_UN)
569
981
 
570
982
 
983
+ # ------------------------------------------------------------------ view sessions
984
+
985
+ SESSIONS_LOCK = 'locks/sessions.lock' # under STATE: the view-session lock (sessions_lock, DEF-1792)
986
+ SESSIONS_POLL = 0.1 # seconds between two tries while another process holds it
987
+ _SESSIONS_DOING = re.compile(r'[A-Za-z0-9_. -]{1,80}\Z')
988
+ _SESSIONS_SINCE = re.compile(r'[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z\Z')
989
+ _sessions_depth = 0 # how many sessions_lock blocks this process is inside (re-entry)
990
+
991
+
992
+ class SessionsBusy(LanesError):
993
+ """sessions_lock was not taken in time: another lanes process is acting on the view sessions."""
994
+
995
+
996
+ def sessions_holder(path=None):
997
+ """Who holds the view-session lock, as sessions_lock recorded it: 'lanes rebuild mac (pid 4242, since
998
+ 2026-10-07T08:40:12Z)'. 'another lanes process' when the record is missing, cut short or not one sessions_lock
999
+ writes: no file text reaches a terminal unchecked."""
1000
+ record = read_json(STATE / SESSIONS_LOCK if path is None else path)
1001
+ if (isinstance(record, dict) and isinstance(record.get('doing'), str) and _SESSIONS_DOING.match(record['doing'])
1002
+ and type(record.get('pid')) is int and record['pid'] > 0
1003
+ and _SESSIONS_SINCE.match(str(record.get('since', '')))):
1004
+ return f"{record['doing']} (pid {record['pid']}, since {record['since']})"
1005
+ return 'another lanes process'
1006
+
1007
+
1008
+ @contextlib.contextmanager
1009
+ def sessions_lock(timeout, doing):
1010
+ """Serialize every check-then-act on the zellij view sessions across processes (DEF-1792): `lanes ensure` (the
1011
+ minute timer, and `--if-layout-changed`, the server update's last step), `lanes rebuild` (also the sidebar's `r`),
1012
+ the create of `lanes attach`, `prepare` and `web`, the end `lanes offboard` gives a view and `lanes device forget`.
1013
+ Each decides from the session list (or the devices/ record) and then creates, kills or deletes a session, or removes
1014
+ the record `ensure` recreates from; interleaved, one could start a second `zellij attach --create-background` for a
1015
+ name another had just killed and was about to create.
1016
+
1017
+ An exclusive flock(2) on STATE/locks/sessions.lock, tried every SESSIONS_POLL s for up to `timeout` s (one line on
1018
+ stderr the first time it is busy, naming the holder). Taken, it records `{pid, doing, since}` for a waiter to name
1019
+ (`doing`: what this process is, e.g. 'lanes rebuild mac'), and empties the record again when the block ends. The
1020
+ lock belongs to the open file: a holder that dies releases it, and no child inherits it (O_CLOEXEC; subprocess
1021
+ closes every other descriptor). Re-entrant within one process — `lanes ensure` rebuilds through `cmd_rebuild` while
1022
+ it holds it. Raises SessionsBusy (naming the holder) when not taken in time, LanesError when the file cannot be
1023
+ opened."""
1024
+ global _sessions_depth
1025
+ if _sessions_depth:
1026
+ _sessions_depth += 1
1027
+ try:
1028
+ yield
1029
+ finally:
1030
+ _sessions_depth -= 1
1031
+ return
1032
+ path = STATE / SESSIONS_LOCK
1033
+ try:
1034
+ path.parent.mkdir(parents=True, exist_ok=True)
1035
+ descriptor = os.open(path, os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW | os.O_CLOEXEC, 0o600)
1036
+ except OSError as error:
1037
+ raise LanesError(f'cannot open the view-session lock {path}: {error.strerror or error}')
1038
+ try:
1039
+ deadline, said = time.monotonic() + timeout, False
1040
+ while True:
1041
+ try:
1042
+ fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
1043
+ break
1044
+ except BlockingIOError:
1045
+ if time.monotonic() >= deadline:
1046
+ raise SessionsBusy(f'{sessions_holder(path)} is acting on the view sessions '
1047
+ f'(waited {timeout:g} s)')
1048
+ if not said:
1049
+ print(f'lanes: {sessions_holder(path)} is acting on the view sessions; waiting up to {timeout:g} s',
1050
+ file=sys.stderr, flush=True)
1051
+ said = True
1052
+ time.sleep(SESSIONS_POLL)
1053
+ os.ftruncate(descriptor, 0)
1054
+ os.pwrite(descriptor, (json.dumps({'pid': os.getpid(), 'doing': doing,
1055
+ 'since': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())})
1056
+ + '\n').encode(), 0)
1057
+ _sessions_depth = 1
1058
+ try:
1059
+ yield
1060
+ finally:
1061
+ _sessions_depth = 0
1062
+ try:
1063
+ os.ftruncate(descriptor, 0)
1064
+ except OSError:
1065
+ pass
1066
+ finally:
1067
+ os.close(descriptor) # releases the lock
1068
+
1069
+
571
1070
  def _require(pattern, value, label):
572
1071
  if not isinstance(value, str) or not pattern.match(value):
573
1072
  raise LanesError(f'Invalid {label}: {value!r}')
@@ -580,9 +1079,20 @@ def _title(value):
580
1079
  return value
581
1080
 
582
1081
 
1082
+ def declared_tmux(registry_doc, name):
1083
+ """The tmux binding ({socketName, sessionName}) one terminals.json declares for its terminal `name` — the terminal's
1084
+ own `lanes.terminals[name].tmux`, else the one recorded under `throttle.terminals[name]` — exactly as
1085
+ _workspace_lanes reads it; None when the file declares none (the manifest's recoveries and overrides are the
1086
+ board's, not the file's). session_actions.promote_to_registered asks it which session a file already binds."""
1087
+ own = ((registry_doc.get('lanes') or {}).get('terminals') or {}).get(name) or {}
1088
+ return own.get('tmux') or ((((registry_doc.get('throttle') or {}).get('terminals') or {}).get(name) or {})
1089
+ .get('tmux'))
1090
+
1091
+
583
1092
  def _workspace_lanes(manifest, containers):
584
1093
  """Port of coder-launch/manage.mjs: explicit tmux bindings only; nothing is ever executed."""
585
1094
  lanes = []
1095
+ layers = [] # (lane, title, host override, container-declared name): _settle_declared_displays
586
1096
  names = set()
587
1097
  repo_ids = set()
588
1098
  for repo in manifest.get('repositories') or []:
@@ -602,7 +1112,6 @@ def _workspace_lanes(manifest, containers):
602
1112
  if digest != source.get('sha256'):
603
1113
  raise LanesError(f"Binding source changed: {source.get('path')}; inspect its tmux targets "
604
1114
  'before updating the saved bindings.')
605
- throttle = ((registry.get('throttle') or {}).get('terminals') or {})
606
1115
  declared = ((registry.get('lanes') or {}).get('terminals') or {})
607
1116
  folder = repo.get('registry', '').split('/.vscode/')[0] if '/.vscode/' in repo.get('registry', '') \
608
1117
  else (manifest.get('remoteShell') or {}).get('cwd') or CONTAINER_HOME
@@ -611,7 +1120,7 @@ def _workspace_lanes(manifest, containers):
611
1120
  own = declared.get(name) or {}
612
1121
  # `lanes cursor-sync` records the binding and the original command next to the terminal, so the
613
1122
  # Cursor-facing `command` can be the uniform `lane open <id>` entry point.
614
- binding = own.get('tmux') or (throttle.get(name) or {}).get('tmux')
1123
+ binding = declared_tmux(registry, name)
615
1124
  create = own.get('create') if 'create' in own else entry.get('command')
616
1125
  recovery = None if own.get('tmux') else (repo.get('bindingRecoveries') or {}).get(name)
617
1126
  if recovery:
@@ -638,11 +1147,15 @@ def _workspace_lanes(manifest, containers):
638
1147
  names.add(title)
639
1148
  color = entry.get('color') if isinstance(entry.get('color'), str) else None
640
1149
  icon = entry.get('icon') if isinstance(entry.get('icon'), str) else None
641
- # Keyed by the DECLARED (unprefixed) terminal name; written by session_actions.rename_registered.
642
- # The override changes `name` only: the id derives from `registry_name` (lane_id_for).
643
- display = (repo.get('displayNames') or {}).get(name)
644
- lanes.append({
645
- 'name': _title(display) if display else title, 'registry_name': title, 'repo': repo_id, 'container': container, 'holder': 'tmux',
1150
+ # Two display layers, both keyed by the DECLARED (unprefixed) terminal name and both changing `name` only:
1151
+ # the id derives from `registry_name` (lane_id_for). The host manifest's `repositories[i].displayNames`
1152
+ # (the operator's override, written by session_actions.rename_registered) wins over the container's own
1153
+ # `lanes.terminals[<name>].displayName` (LNS-33: what the repository, or an agent in the container,
1154
+ # declares). The container layer is read leniently, like color/icon: a value that is no usable lane name
1155
+ # is ignored, never a board error — and it is settled only once the whole board is known (below).
1156
+ override = (repo.get('displayNames') or {}).get(name)
1157
+ lane = {
1158
+ 'name': _title(override) if override else title, 'registry_name': title, 'repo': repo_id, 'container': container, 'holder': 'tmux',
646
1159
  'tmux': {'socket': _require(IDENT, binding.get('socketName'), 'tmux socket'),
647
1160
  'session': _require(TMUX_SESSION, binding.get('sessionName'), 'tmux session')},
648
1161
  'focus': entry.get('focus') is True,
@@ -650,10 +1163,56 @@ def _workspace_lanes(manifest, containers):
650
1163
  'icon': LANE_ICONS.get(icon, '💻'),
651
1164
  'create': create if isinstance(create, str) and LANE_OPEN not in create else None,
652
1165
  'folder': folder, 'source': repo.get('registry'),
653
- })
1166
+ }
1167
+ lanes.append(lane)
1168
+ layers.append((lane, title, override or None, _declared_display(own.get('displayName'))))
1169
+ _settle_declared_displays(layers)
654
1170
  return lanes
655
1171
 
656
1172
 
1173
+ def _settle_declared_displays(layers):
1174
+ """The container layer names only its OWN lane (LNS-33 review fix). The "Duplicate lane names" refusal above
1175
+ covers titles; a container-declared name is not refused but DROPPED when another lane of the same board also
1176
+ answers to it — that lane's title (the name it shows the moment its override is cleared), its host `displayNames`
1177
+ override (the operator wins), or its own container-declared name (dropped on both sides, so terminal order never
1178
+ decides who keeps it). Names are compared as a row reads them (`_shown_key`). A dropped name leaves the lane on
1179
+ its override or title, with no board error. A kept one is carried as `declared_display` — only then, so a board
1180
+ that declares none publishes the registry it did before — because a rename back to it drops the override; a
1181
+ dropped one is not, so a rename back to the title is "back to the default" (rename_registered).
1182
+ `layers` is [(lane, title, override or None, declared or None)] in board order; the lanes are updated in place."""
1183
+ holders = {}
1184
+ for lane, title, override, declared in layers:
1185
+ for key in {_shown_key(value) for value in (title, override, declared) if value}:
1186
+ holders[key] = holders.get(key, 0) + 1
1187
+ for lane, title, override, declared in layers:
1188
+ if declared and holders[_shown_key(declared)] == 1:
1189
+ lane['declared_display'] = declared
1190
+ if not override:
1191
+ lane['name'] = declared
1192
+
1193
+
1194
+ def _shown_key(value):
1195
+ """A lane name as a row reads it: Unicode compatibility forms folded (NFKC), invisible format characters (Cf:
1196
+ zero-width space/joiner, word joiner, BOM) dropped, whitespace runs collapsed and trimmed. Two names with the same
1197
+ key are two rows nobody can tell apart; an empty key is a row that reads blank."""
1198
+ text = ''.join(ch for ch in unicodedata.normalize('NFKC', value) if unicodedata.category(ch) != 'Cf')
1199
+ return ' '.join(text.split())
1200
+
1201
+
1202
+ def _declared_display(value):
1203
+ """A container-declared `displayName` (terminals.json), or None when it is no usable lane name: not a string,
1204
+ reading blank (whitespace or invisible format characters only), no UTF-8 text (a lone surrogate JSON can spell,
1205
+ which would make every projection's write raise), or holding a control character (the `_title` rule). Never
1206
+ raises — the container's file must not be able to take a board down over a label."""
1207
+ if not isinstance(value, str) or not _shown_key(value):
1208
+ return None
1209
+ try:
1210
+ value.encode('utf-8')
1211
+ return _title(value)
1212
+ except (LanesError, UnicodeEncodeError):
1213
+ return None
1214
+
1215
+
657
1216
  def _workspace_cwd(manifest):
658
1217
  cwd = (manifest.get('remoteShell') or {}).get('cwd')
659
1218
  if cwd:
@@ -927,9 +1486,11 @@ def publish_registry(registry):
927
1486
  """Publish a prepared registry while holding registry_lock(). Every projection is written only when its content
928
1487
  changed (plan Task 17): `lanes sync` runs every 2 minutes and a registry.json rewritten with nothing but a new
929
1488
  `generated_at` made statusd, notify and every sidebar reload it. An unchanged registry.json therefore keeps its
930
- OLD `generated_at`; the sync itself is recorded in `registry.synced`, which only `lanes doctor` reads."""
1489
+ OLD `generated_at`; the sync itself is recorded in `registry.synced`, which only `lanes doctor` reads.
1490
+ -> [(container, why)] for each container whose `~/.lz/registry.json` was refused (DEF-1796): it costs only that
1491
+ container, never the other projections; `lanes sync` reports it."""
931
1492
  write_resolve_files(registry)
932
- export_container_registries(registry)
1493
+ _written, refused = export_container_registries(registry)
933
1494
  for workspace in registry['workspaces']:
934
1495
  focus = next((lane for lane in workspace['lanes'] if lane.get('focus')), None)
935
1496
  first = focus or (workspace['lanes'][0] if workspace['lanes'] else None)
@@ -937,6 +1498,7 @@ def publish_registry(registry):
937
1498
  if not _same_json(STATE / 'registry.json', registry):
938
1499
  atomic_write(STATE / 'registry.json', json.dumps(registry, indent=1, ensure_ascii=False) + '\n')
939
1500
  atomic_write(STATE / 'registry.synced', f"{registry['generated_at']}\n")
1501
+ return refused
940
1502
 
941
1503
 
942
1504
  def registry_synced_at():
@@ -974,7 +1536,13 @@ def write_resolve_files(registry):
974
1536
 
975
1537
  def export_container_registries(registry):
976
1538
  """/home/coder/.lz/registry.json inside each container (written through the volume path): what `lane open`
977
- needs — holder, exact target, create recipe, folder. Only where the lane contract is installed."""
1539
+ needs — holder, exact target, create recipe, folder. Only where the lane contract is installed.
1540
+
1541
+ LNS-32: each entry also says who declares the lane and where, for the in-container Agent API (`lane-mcp`):
1542
+ `source` (the container's spelling of its terminals.json), `repo` (the manifest's repository id) and
1543
+ `registry_name` (the declared terminal name with the repository prefix — what the id derives from, whatever the
1544
+ display name), all null for a session adopted through `registered-sessions.json` (`registered_session: true`),
1545
+ plus `container`. Additive within version 1: `lane` and the Studio's registry reader read by key."""
978
1546
  per_container = {}
979
1547
  for workspace in registry['workspaces']:
980
1548
  for lane in workspace['lanes']:
@@ -982,17 +1550,47 @@ def export_container_registries(registry):
982
1550
  continue
983
1551
  per_container.setdefault(lane['container'], {})[lane['id']] = {
984
1552
  'name': lane['name'], 'ws': workspace['id'], 'holder': lane['holder'], 'tmux': lane.get('tmux'),
985
- 'zmx': lane.get('zmx'), 'create': lane.get('create'), 'folder': lane.get('folder')}
986
- written = []
1553
+ 'zmx': lane.get('zmx'), 'create': lane.get('create'), 'folder': lane.get('folder'),
1554
+ 'source': lane.get('source'), 'repo': lane.get('repo'), 'registry_name': lane.get('registry_name'),
1555
+ 'container': lane['container'], 'registered_session': bool(lane.get('registered_session'))}
1556
+ written, refused = [], []
987
1557
  for container, lanes in per_container.items():
988
1558
  home = (registry['containers'].get(container) or {}).get('home')
989
- if home and (Path(home) / '.lz/bin/lane').exists():
1559
+ if not home:
1560
+ continue
1561
+ # `~/.lz` belongs to the container: walked without following a link, read without blocking on a FIFO,
1562
+ # replaced by rename (DEF-1796: a planted absolute symlink redirected this write on the HOST — into another
1563
+ # container's ~/.lz through its `~/.lz/<name>` host link — and a FIFO at registry.json hung the sync).
1564
+ try:
1565
+ fd = open_real_dir(home, ('.lz',))
1566
+ except FileNotFoundError:
1567
+ continue # no ~/.lz: the lane contract is not installed there
1568
+ except OSError as error:
1569
+ refused.append((container, f'~/.lz is not a real directory ({type(error).__name__}: {error})'))
1570
+ continue
1571
+ try:
1572
+ try:
1573
+ os.stat('bin/lane', dir_fd=fd)
1574
+ except FileNotFoundError:
1575
+ continue # a ~/.lz without `lane`: not installed either
990
1576
  document = {'version': 1, 'generated_at': registry['generated_at'], 'lanes': lanes}
991
- if not _same_json(Path(home) / '.lz/registry.json', document): # `generated_at` alone is no change
992
- atomic_write(Path(home) / '.lz/registry.json',
993
- json.dumps(document, ensure_ascii=False, indent=1) + '\n', mode=0o644)
1577
+ old = read_regular_at(fd, 'registry.json', EXPORT_READ_MAX)
1578
+ try:
1579
+ old = json.loads(old) if old is not None else None
1580
+ except ValueError:
1581
+ old = None
1582
+ if not _same_document(old, document): # `generated_at` alone is no change
1583
+ atomic_write_at(fd, 'registry.json', json.dumps(document, ensure_ascii=False, indent=1) + '\n',
1584
+ mode=0o644)
994
1585
  written.append(container)
995
- return written
1586
+ except OSError as error:
1587
+ refused.append((container, f'~/.lz/registry.json not written ({type(error).__name__}: {error})'))
1588
+ finally:
1589
+ os.close(fd)
1590
+ return written, refused
1591
+
1592
+
1593
+ EXPORT_READ_MAX = 16 << 20 # a ~/.lz/registry.json bigger than this is not ours: it is replaced, never read whole
996
1594
 
997
1595
 
998
1596
  def load_registry():