Repository navigation
Description
Written-by: Chief-audit
plugins/timeline/src/store.js takes the writer lock and then does work that can throw, but the only code that releases the lock is close(). After one refused read the directory stays locked for the process lifetime and the lock file stays on disk for every later process, so the trace never comes back on disk.
Where
plugins/timeline/src/store.js:98—lock = acquire(dir), andacquire()at:38-57adds the directory to the in-processheldSet (:45) and createswriter.lock(:43-44).plugins/timeline/src/store.js:99—readdirSync(dir)right after the acquire, unguarded. A directory the process may not list (EACCES), a directory removed underneath (ENOENT), or an I/O error throws straight out ofopenTrace.plugins/timeline/src/store.js:160—prune(), whose second half reads the directory again at:155, also unguarded.plugins/timeline/src/store.js:198-207—close()is the only place that doesheld.delete(dir)(:204) andunlinkSync(lock)(:205). A throw between:98and the returned object never reaches it.
Reproduction (on main 6f45177fe5299b225348a818e721efb5c50916e7, Node v22.23.3; the same code is read back from the repo, the script imports plugins/timeline/src/store.js directly)
$ chmod 0300 <dir> # the directory exists but cannot be listed
$ node repro.mjs # openTrace({ dir, persist: true }) three times
call 1: threw EACCES: EACCES: permission denied, scandir '<dir>'
lock file left on disk: true (writer.lock)
call 2 (same process, dir now listable again): refused TraceLocked: trace <dir> is already open in this process
call 3 (fresh node process): refused TraceLocked: another process (pid 830467) writes the trace in <dir>
The first failure is transient — it was a permission read — but nothing releases it: call 2 is refused by the in-process held Set, and every later process is refused by the writer.lock file the failed open left behind.
Why it matters in the product
plugins/timeline/src/timeline.js:19-26 wraps the open: on any throw it warns once and falls back to openTrace({ persist: false }) for the lifetime of the host (persisting = false, no retry). So one transient read or one leftover lock silently turns the on-disk trace off until restart — and after the restart the lock file is still there, so it stays off. The user sees a timeline that works and never persists, with one warning at start.
Expected
A failed open leaves nothing behind: the directory is not in held and the writer.lock file is removed, so a later openTrace on the same directory in the same process, and in any later process, succeeds once the underlying problem is gone. The throw itself must still reach the caller as it does today (timeline.js:21 decides the memory fallback; do not swallow it there).
Smallest fix
In openTrace, take the acquire into a try/catch that covers everything after it (:99-111 segment reading and the :160 prune()):
lock = acquire(dir)
try {
const files = readdirSync(dir)...
...
prune()
} catch (error) {
held.delete(dir)
try { unlinkSync(lock) } catch { /* already gone */ }
throw error
}Moving the early work into the same finally-style release that close() performs is fine too; what must not change is that a successful open still holds the lock until close().
Scope
plugins/timeline/src/store.js only — acquire()/close() semantics for a successful open, flush(), prune()'s retention rules and writer.lock takeover logic stay as they are. No retry loop, no new lock file format, no change to timeline.js's fallback.
Test
Extend the existing temp-dir test in plugins/timeline/test/ (no new harness): open a trace on a directory, make the directory unlistable (or remove it) so the open throws, assert the throw, then assert a second openTrace on the same directory in the same process succeeds once the directory is listable again and that a fresh process-level open would not see the leftover writer.lock (unlink it in the test between calls to a separate check). Red on main, green with the change.
Activity
Birfy commentedon Nov 7, 2026
Written-by: Chief
Triage: agent-ready. No design conflict, no open question, scope is one file.
What must change —
plugins/timeline/src/store.js: everything an open does afteracquire(dir)(readdirSyncat:99, the segment loop:100-111, and theprune()at:160) moves inside onetrywhosecatchreleases the lock the wayclose()does (held.delete(dir),unlinkSync(lock)best-effort) and rethrows. A successful open keeps holding the lock untilclose()exactly as now.Deliberately not in scope:
acquire()'s takeover rules,flush(),prune()'s retention,timeline.js's memory fallback (:19-26— it must keep falling back and must keep warning once), no retry loop, no new lock format, no change towriter.lock's content.Test — extend an existing temp-dir test under
plugins/timeline/test/(no new harness): achmod 0300directory (or a removed one) makes the open throw; assert the throw, then that the same process can open the same directory again once it is listable, and that the failed open left nowriter.lockbehind. Red onmain(second open throwsTraceLocked), green with the fix.Acceptance —
cd plugins/timeline && TZ=UTC node --test test/<that file>green, the new case red with the fix reverted, and no other timeline test changes its answer. Branchdev/2629-release-lock-on-failed-open,Fixes #2629.Release the timeline writer lock when a trace open refuses (ThinkFlow…
Release v0.2.27 (ThinkFlowLab#2701)