Skip to content

Go back to waiting when the watch folder disappears, and stop cleanly during start-up - #390

Merged
wasimxyz merged 16 commits into
stagingfrom
watcher-folder-and-startup
Nov 14, 2026
Merged

wasimxyz merged 16 commits into
stagingfrom
watcher-folder-and-startup

Conversation

@wasimxyz

@wasimxyz wasimxyz commented Nov 13, 2026 •

Copy link
Copy Markdown
Member

What changes

Watcher 1.4.3 fixes two things.

A watch folder that disappears while the watcher runs. Before, a watcher whose folder was deleted or unmounted kept reporting watching, because nothing looked at the folder once watching had started. Now:

  • Every heartbeat tick, watchdog's report that the watch folder itself was deleted, and an upload thread whose look finds the folder itself unavailable ask the supervisor to check the folder. The check runs on the supervisor's poll thread, not on the heartbeat, watchdog or upload thread. A delete inside the folder is still ignored.
  • The look at the folder runs on its own daemon thread, and the poll thread waits for it for 10 seconds. A stat on a hung share can block for a minute or for good, and the poll thread is the one that sees a stop. A look that takes longer counts as a problem, watch_directory not responding: <path>. A look that is still running is waited for by the next ones and not started again. The waiting state's check and the start of a runtime use the same look. A caller that is stopping doesn't wait for it: FolderProbe.look takes a cancel callable and waits in steps of 0.1 s, and the supervisor's checks and the uploader pass their stop signal. The look that starts a runtime keeps its 10 seconds.
  • One failed look is not enough. A second look 3 seconds later has to fail too, and its reason is the one reported. A share that drops for a moment costs no upload and no rescan, and a folder that is still being deleted on Windows is reported once, as "not found", and not first as "not readable".
  • On a confirmed problem the runtime is torn down as for a config rebuild and the watcher enters the waiting state with the reason, as it does when the folder is missing at start. It logs one warning, queues the watch_directory_unavailable error, and queues no second watcher_started event, because the watcher has not stopped.
  • The move is bounded where a dead share could hold it. Uploads in progress get 10 seconds (not the 120 seconds of a config rebuild). The observer's stop, the wait for a heartbeat tick and the event flush get 5 seconds each. An upload thread that is blocked reading the share can't be cut off, so it is left behind with its runtime's state database open, and the next runtime starts only once it has exited. Every wait of the move ends on a stop, and the runtime is then left in place for stop, which cuts its uploads off within its own budget (the stop is also checked after each look and before the teardown). The first heartbeat of the waiting state, and a request already in flight, still have only the HTTP timeouts. The log says what was left behind, the upload threads or only a heartbeat tick.
  • Between the folder disappearing and the check, every file in it used to look missing, so the upload worker dropped each pending_uploads row for good and counted "Queued file missing" attempts toward the cancel. The uploader now asks whether the whole folder is gone first. If it is, the row or queue entry stays, nothing is counted, and the check is requested at once.
  • A folder that is there can still leave the watcher blind. The supervisor now records the folder's device and inode number when it starts watching, and compares it at each check, which catches a folder that was renamed and made again, or one that something was mounted on. An inode number of 0 is ignored. A runtime whose monitor stopped watching is rebuilt as before. A rebuilt runtime that goes blind again within 2 minutes is not rebuilt again. When watchdog has stopped watching, the watcher waits with watch_directory can't be watched: <path> and retries on the waiting state's backoff, so an emitter that dies at once doesn't cause a rescan at every check. When watchdog is still watching and only the folder's identity differs (FUSE mounts without use_ino, SMB or virtual drives without persistent file IDs report a new number at every look), the watcher keeps the runtime, logs both identities once, and stops comparing the identity until the next config change or restart, so live uploads don't stop. The rebuild of a blind runtime is bounded like the move, because the identity can change while upload threads are blocked on the old filesystem.
  • A rebuild whose start fails, or whose folder is gone when it starts, no longer queues a watcher_started event that says the watcher resumed.
  • When the folder is back, the existing waiting-state check starts a new runtime and a watcher_started event says it resumed. A runtime does not start next to threads that the move left behind on the same folder. While they run, the waiting reason says uploads stuck on <folder> have not ended, in place of the stale "not found". A config change that moves the watch folder doesn't wait for them (they can't touch the new folder), and one that keeps the folder waits up to 2 minutes, logs every minute, and ends on a stop. Asking whether those threads have exited logs nothing, where it wrote a line about twice a second.
  • During a stop, an upload thread doesn't look at the folder when a file can't be found: a stop is not a missing file.
  • One "not responding" while waiting no longer ends the backoff of a start that keeps failing. A "not found", or two problems in a row, still do.
  • The Windows service no longer logs "is running" or "is waiting" and then "was asked to stop while it was starting". It logs only the second. The end-to-end drivers still see the end of the start.

A stop during watch start-up. watch installed its Ctrl+C and SIGTERM handlers only after the supervisor had started, so a SIGTERM during the config sync, the first heartbeat or the start-up scan ended the process and Ctrl+C raised KeyboardInterrupt, with no stopped heartbeat and no watcher_stopped event. The handlers are now installed before the start. They call WatcherSupervisor.request_stop, the supervisor's one stop signal, and so does the Windows service (from a thread that starts before supervisor.start()). The start-up scan reads it between entries and ends early, so a stop during a long scan is acted on within seconds, and the normal clean stop follows, exit code 0. The files the scan hadn't reached are scanned at the next start: the scan writes nothing to the state database for them, and a baseline scan on the first start of a new-only environment records nothing at all when it stops early, because a partial baseline would make the next start upload the rest of the backlog as new. The upload worker isn't started for a watcher that is already stopping, and poll leaves a rebuild or a folder check alone once a stop is requested. The config sync and the first heartbeat are not cut short: each has its HTTP timeout.

The release is watcher 1.4.3 (version, lock file and docs snapshots). There is no changelog entry, because it is a watcher-only release.

Docs

  • User docs: the waiting state in concepts.mdx, manage-watchers.mdx and troubleshoot-a-watcher.mdx now says the watcher also goes back to waiting when its folder disappears while it runs, and resumes when it is back. windows-service.mdx says a stop ends the scan at its next file from 1.4.3.
  • docs/developer/watcher.md describes the stop signal and the scan stop, the bounded and cancelable look and the second look, what the move to the waiting state includes, which of its waits are bounded and end on a stop and what a stop still waits for, the folder identity check and its fallback, the wait for a runtime that keeps going blind, and the waits for threads that a move left behind. The known-bug examples in the end-to-end docs use a placeholder that is not a real issue number.

How it was tested

  • make check: clean, and it rewrote nothing.
  • make py-test-unit: 2486 passed. The changed unit test files also pass on Python 3.12 in a separate environment (452 passed).
  • make py-test-integration (with its own test database): 95 passed.
  • End-to-end on macOS under the watch command: watcher/e2e/test_setup_startup.py and test_shutdown.py, 32 passed. The Windows service variants were not run here.
  • uv run pytest watcher/e2e --collect-only collects 208 tests, and watcher/tests/test_e2e_catalog.py passes (56). make docs-catalogs changes no snapshot.
  • New unit tests cover each point above. A stop during a hung look, during the second look and during the move (each returns within about a second, and the stop that follows is clean), during a blind rebuild, during the wait for left-behind threads, and during an upload thread's look; the identity that changes at every look (the runtime is kept, both identities are logged once, and a config change compares again); the threads left behind (per folder, bounded, with a reason, and no log flood); the backoff reset rule; and the uploader's stop. The timing tests with short probe timeouts now use 2 second timeouts and a longer hang. The timing-sensitive test files were run 5 times in a row, and once more as three copies in parallel, with no failure.
  • Each new behavior was reverted once, and the tests for it fail. Where one guard was redundant with another (a stop check in the folder check and one at the top of the teardown), both were removed.
  • A stop in the middle of a long scan, or during a move on a dead share, is not an end-to-end test, because it can't be hit from outside. The unit tests drive it.

🤖 Generated with Claude Code

wasimxyz and others added 6 commits November 13, 2026 11:33
… runs

A watcher whose watch folder was deleted or unmounted kept reporting
`watching`, because nothing looked at the folder once watching had started.

Every heartbeat tick of a running watcher, and watchdog's report that the
watch folder itself was deleted or moved, now ask the supervisor to check the
folder. The check runs on the supervisor's poll thread. If the folder can't be
listed, the runtime is torn down and the watcher enters the waiting state with
the reason, as it does when the folder is missing at start. It queues the
`watch_directory_unavailable` error and no second `watcher_started` event.
Uploads in progress get 10 seconds to finish instead of the 120 seconds of a
config rebuild, because the parts of a large file that haven't started can't
open the file any more. The existing waiting-state check starts a new runtime
once the folder is back.

A folder that was deleted and created again before the check is also found:
watchdog stops watching the deleted folder, so the runtime is rebuilt when its
file monitor is no longer watching.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
`watch` installed its Ctrl+C and SIGTERM handlers only after the supervisor
had started. A SIGTERM during the config sync, the first heartbeat or the
start-up scan ended the process, and Ctrl+C raised KeyboardInterrupt, so
neither sent a final `stopped` heartbeat or a `watcher_stopped` event.

The handlers are now installed right after the supervisor is built. A stop
that arrives during start-up is recorded, and once the start returns
`run_supervisor` returns at once and the command stops the watcher as for any
other Ctrl+C, with exit code 0, as the Windows service already does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
… folder

The tests that drive the check with a heartbeat tick could pass on
watchdog's own report of the deleted folder instead, which hid a missing
tick hook. They now ignore events from watchdog's thread, and a tick has to
ask for the check itself.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Describe how a running watcher moves to the waiting state when its folder
disappears, why that move waits 10 seconds for uploads, and that `watch` now
installs its signal handlers before the start. The known-bug examples in the
end-to-end docs no longer name bugs that are fixed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
@vercel

vercel Bot commented Nov 13, 2026 •

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
data-hub Ready Ready Preview Nov 13, 2026 11:58pm UTC
data-hub-docs Ready Ready Preview Nov 13, 2026 11:58pm UTC

Request Review

wasimxyz and others added 4 commits November 13, 2026 12:47
A stop that arrived while the watcher was starting used to wait for the
start-up scan, which takes minutes on a large folder. Ctrl+C printed
"Shutting down..." and nothing happened.

The supervisor now has one stop signal, `request_stop`. The `watch`
command's signal handler and the Windows service's stop both call it, the
service from a thread that starts before `supervisor.start()`. It wakes
`poll`, and the file monitor's scan reads it between entries and ends
early. The scan writes nothing to the state database for a file it did not
reach, and a baseline scan on the first start of a `new-only` environment
records nothing at all when it stops early, so the next start scans or
seeds the whole folder. The upload worker is not started for a watcher that
is already stopping, and `poll` leaves a rebuild or a folder check alone
once a stop is requested.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…ing to waiting

The running-folder check made `exists`, `is_dir` and `scandir` calls on the
thread that handles stops. On a hung network share one of them can block for
a minute or for good, so a stop was ignored while the watcher waited for it.

The look at the folder now runs on a daemon thread that is waited for ten
seconds (`FolderProbe`). A look that takes longer counts as a problem
("not responding"), and a hung look is waited for by the next ones and not
started again. The waiting state's check and the start of a runtime use the
same look.

The move to the waiting state is bounded where a dead share could hold it:
the observer's stop runs on its own thread, the heartbeat join and the event
flush have limits, and upload threads that the cut-off can't reach are left
behind with their state database open. The next runtime waits until they
have exited.

One failed look no longer moves the watcher. A second look three seconds
later has to fail too, and its reason is the one reported, so a share that
drops for a moment costs no upload, and a folder that is still being deleted
on Windows is reported once, as "not found".

Before an upload thread drops a pending upload, or counts a missing or
failed attempt, it asks whether the whole watch folder is gone. If it is,
the row or queue entry stays, nothing is counted, and the supervisor is
asked to check the folder.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
A folder that is there can still leave the runtime blind. watchdog's emitter
may have stopped, or the folder may not be the one the runtime started on.
Renaming a folder and making another at its path, mounting something on it,
or moving it to the Recycle Bin and making it again are not reported by
watchdog 6.0.0.

The supervisor now records the folder's device and inode number from the look
that found it fit to watch, and compares it at each check. An inode number of 0,
which some SMB servers report for everything, is ignored. A mismatch, like a
monitor that stopped watching, rebuilds the runtime.

A runtime that goes blind again before it has watched for two minutes is not
rebuilt again, because that would send a heartbeat and a full scan at every
check on a share whose emitter dies at once. The watcher waits with the reason
"watch_directory can't be watched" and tries again on the waiting state's
backoff, which each further blind runtime lengthens.

A rebuild whose start fails, or whose folder is gone when it starts, no
longer queues a `watcher_started` event that says the watcher resumed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…scan

The developer docs now say what the move to the waiting state includes and
which of its waits are bounded, how the folder is looked at and confirmed,
how a folder that was replaced or can't be watched is handled, and how a stop
ends the start-up scan under `watch` and the Windows service. The user docs
say that a stop no longer waits for the whole scan, and that a drop of a
moment doesn't count.

The examples of an issue number in the end-to-end docs and docstrings now use
a placeholder that can't be mistaken for a real issue.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
wasimxyz and others added 5 commits November 13, 2026 14:06
watchdog's `Observer.stop` has no timeout, so the monitor already runs it on
its own thread and waits for it for a limited time. That wait was one blocking
join, which a stop of the watcher could not end. `FileMonitor.stop` now takes
a `should_cancel` callable and waits in steps of 0.1 s, so a caller that is
stopping goes on within a fraction of a second and leaves the observer's
thread to finish by itself.

`FileMonitor.has_exited` says whether the stability checker is gone without
logging. `join` logs "FileMonitor stopped" every time it finds the checker
gone, which made a caller that asks over and over write a line each time.

The test of the observer's wait asserts the timeout it passes, with the clock
held still, where it used to compare it with the wall clock.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
When a file can't be found, the uploader asks whether the whole folder is
gone before it drops the file's row or counts an attempt. That look has a
limit of its own (10 s on a hung share), which `Uploader.abort` can't cut
short, so an upload thread in it during a stop held the stop up.

`Uploader._folder_is_gone` now answers yes at once when a stop was requested,
without a look: a stop is not a missing file. The file's row or queue entry
stays, and the next start deals with it. `UploadQueueWorker.has_exited` says
whether the worker's thread is gone without signalling it.

The tests have the stop arrive as the file is looked for, which is the only
moment the check can see one: every caller checks for a stop before it gets
that far.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
The service logged "is running" or "is waiting" after its start even when a
stop had arrived during it, and then "was asked to stop while it was
starting". The two lines contradicted each other. The first is now left out
when a stop is pending, as the `watch` command leaves out its status lines.

The end-to-end drivers wait for "is running" or "is waiting" as the ready
signal, or for the service to have stopped, which a service that was asked to
stop does, so the drivers still see the end of a start. The two unit tests
that read "is running" now stop the loop after it has started.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
… threads from stalling the watcher

A stop during a folder check waited for the whole check. The look at the
folder always waited its full 10 s on a hung share, and neither the second
look, the move to the waiting state nor the rebuild of a blind runtime looked
at the stop, so a stop could start its budget about 30 s late and overrun the
service's wait hint, or miss a Windows preshutdown's 7 s altogether.

* `FolderProbe.look` takes a `should_cancel` callable and waits in steps of
  0.1 s. A look that a stop cancelled says `None`, and the caller does
  nothing. The supervisor's checks pass its stop signal, and so does the
  uploader's callback, which now ends its look on a stop too.
* The checks look at the stop after each look and before the teardown. The
  drain of the move and of a blind rebuild, the wait after the cut-off, the
  observer's stop and the wait for a heartbeat tick all end on a stop. The
  runtime is then left in place for `stop`, which cuts its uploads off within
  its own budget.

A folder whose inode number isn't stable no longer stops being watched live.
On FUSE mounts without `use_ino` and on SMB or virtual drives without
persistent file IDs, two identity mismatches in the window used to send the
watcher to "can't be watched" with a growing backoff. That wait is now only
for a monitor that has stopped watching. On a second mismatch alone the
watcher logs both identities, stops comparing the identity until the next
config change or restart, and keeps the runtime.

The threads of a runtime that the move had to leave behind no longer block
config changes without limit. `_reap_cut_off` asks about the threads without
logging, where `FileMonitor.join` wrote a line at every ask. A change that
moves the watch folder doesn't wait for them, because they can't touch the new
folder and their rows are dropped. A change that keeps the folder waits up to
two minutes, logs every minute, and ends on a stop. If they are still there,
the start waits with the reason "uploads stuck on <folder> have not ended",
which the waiting state's check now sets in place of a stale "not found". A
rebuild of a blind runtime is bounded the same way, because the identity can
change while upload threads are blocked on the old filesystem.

Smaller changes: the log of the move says what was left behind (upload
threads, or only a heartbeat tick), a single "not responding" no longer ends
the backoff of a start that keeps failing (a "not found" or two problems in a
row still do), and the tests whose looks must succeed use 2 s timeouts with a
longer hang.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…cut-off waits

The developer docs now say which waits of a folder check end on a stop and
what a stop still waits for (a request in flight, the first heartbeat of the
waiting state, and the look that starts a runtime). They give the corrected
time the move takes on a dead share, which the earlier line left out, and say
that only a request in flight and the waiting state's first heartbeat remain
uncovered.

They also describe the identity fallback for filesystems that give a folder no
lasting identity, the waits for threads that an earlier move left behind (per
folder, bounded for a config change, and with a reason), the rule for when
the waiting state's backoff is cleared, and that an upload thread asks for a
folder check only when its look finds the folder itself unavailable.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@wasimxyz
wasimxyz marked this pull request as ready for review November 14, 2026 00:04
@wasimxyz
wasimxyz merged commit 8e3105f into staging Nov 14, 2026
8 checks passed
@wasimxyz
wasimxyz deleted the watcher-folder-and-startup branch November 14, 2026 00:22

This branch was successfully deployed

2 active deployments
Preview – data-hub — f0c9e0e5 Deployed Nov 13, 2026 by vercel[bot]
Preview – data-hub-docs — f0c9e0e5 Deployed Nov 13, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant