Conversation
| this.options.onDidChangeFile([ | ||
| { type: this.options.vs.FileChangeType.Deleted, uri: this.options.uri }, | ||
| ]); | ||
| this.dispose(); |
There was a problem hiding this comment.
Self-disposing here makes the ref-counted API awkward for callers. After this runs, any subsequent release(), addRef(), start(), suspend(), or resume() call from the owner throws via guardDisposed(). The orchestration layer that holds the addRef() will, in the natural cleanup path, want to call release() on the watch handle and instead has to special-case isDisposed.
Two options worth considering:
- Don't auto-dispose. Set an internal
isTerminatedflag, stop the runner, and letrelease()continue to be safe (no-op past 0). - (Preferred) Make
release()/addRef()tolerant of the disposed state (return currentrefCountinstead of throwing), and clearly document the lifecycle.
| private async poll(signal: AbortSignal): Promise<void> { | ||
| if (this.isSuspended || Date.now() < this.nextPollTimeMs) { | ||
| return; | ||
| } |
There was a problem hiding this comment.
The backoff is implemented by short-circuiting the poll method, but the runner is still ticking at intervalMs and invoking poll() on every interval. A couple of consequences:
- The effective wake-up cadence stays at
intervalMseven when backoff is e.g. 5 minutes. The runner does work (albeit, cheap) every interval, ticks the abort signal lifecycle, etc. - The actual delay between retries is
intervalMs-rounded. WithintervalMs=5000and first-failurecurrentBackoffMs=5000, the next retry happens at the next interval tick (5s) regardless. The exponential growth only manifests at intervals ofintervalMs.
For real exponential backoff with quiet idle periods, consider stopping the runner on failure and using a setTimeout to restart it after currentBackoffMs.
| } | ||
|
|
||
| log.warn(`Unable to poll ${this.options.uri.toString()}`, error); | ||
| this.nextPollTimeMs = Date.now() + this.currentBackoffMs; |
There was a problem hiding this comment.
Most exponential backoff implementations double before scheduling so the first retry already has a longer delay than the baseline. It's also probably overkill here, but most have jitter.
There was a problem hiding this comment.
I don’t think jitter is necessary for this single-user VS Code polling path, but I’ll make the retry sequence explicit in the tests and use the revised scheduling model consistently.
| } | ||
| this.refCountValue -= 1; | ||
| if (this.refCountValue === 0) { | ||
| this.runner.stop(); |
There was a problem hiding this comment.
If a poll is in flight when release() brings the count to 0, the abort propagates to the request and the task rejects with an abort-type error. That error is not a ResponseError 404 so it hits the warn/backoff branch in handlePollError. The result: after the user has released all watches, the poller still mutates nextPollTimeMs and currentBackoffMs via the in-flight task that resolves after the synchronous reset on lines 131-132. If start() is called again later, the poller starts in a backed-off state with no obvious cause.
Severity is low (the abort error path runs only once and the values get re-clobbered on the next successful poll), but the snapshot/backoff reset here isn't actually authoritative.
There was a problem hiding this comment.
I verified this race. An in-flight request can still complete after release()/stop resets local state. I’ll add an in-flight dispose/abort test and guard state mutation after awaits with signal.aborted / disposed checks.
| continue; | ||
| } | ||
| seen.add(key); | ||
| coalesced.push(event); |
There was a problem hiding this comment.
Sorry if I'm tracing this wrong, but what case does this defend against? I'm strugling to see how duplicates can be produced. Each path appears at most once in previous and at most once in current, and the type-flip branch produces (Deleted, Created) pairs which differ by type.
There was a problem hiding this comment.
No you are not wrong. I don’t see a real duplicate case inside one directory diff either. I’ll remove coalesceEvents() from DirectoryPoller.
| mtime: contents.lastModified | ||
| ? new Date(contents.lastModified).getTime() | ||
| : 0, | ||
| size: contents.size ?? 0, |
There was a problem hiding this comment.
Should we key on the URI string (after normalization)?
There was a problem hiding this comment.
In my opinion, the current Jupyter path key is already sufficient for one poller. But keying by normalized emitted URI string should be clearer and better aligned with the events. I am considering to switch the snapshot map key to the child URI string. Do you have any thoughts on this?
| return; | ||
| } | ||
| this.isSuspended = false; | ||
| this.nextPollTimeMs = 0; |
There was a problem hiding this comment.
resume() unconditionally resets nextPollTimeMs = 0, which means a resume during a long backoff window discards the backoff. If suspend() was called while we were backing off after repeated failures, resume() triggers an immediate poll attempt instead of respecting the remaining backoff. Probably fine for a user-initiated resume ("I want to see the latest now"), but is this the intended behavior?
If resume is ever triggered programmatically in response to a network/state event, you could thrash a failing endpoint. No action required if this is by design, if so just leave a comment in the docblock.
There was a problem hiding this comment.
Current behavior intentionally probes immediately on resume, but that is not documented. Since focus regain is user-visible and the user likely wants a fresh check, I’d keep the immediate refresh behavior and document that it bypasses the pending retry delay while preserving the current backoff level for subsequent failures.
| } | ||
| this.isStarted = true; | ||
| this.runner.start(StartMode.Immediately); | ||
| } |
There was a problem hiding this comment.
Nit: start() silently no-ops when refCountValue === 0. That's reasonable, but consider whether start() should call addRef() implicitly, or whether the contract is that addRef() is always called by an outer factory. If the latter, document on the class that start() requires at least one addRef() first. Otherwise debugging "why is my poller not polling?" can get confusing.
|
|
||
| await advance(); | ||
| sinon.assert.callCount(client.get, 5); | ||
| }); |
There was a problem hiding this comment.
This test confirms that the backoff throttles polls but doesn't make obvious the timing-vs-interval interaction. With intervalMs=5000 and the first failure setting nextPollTimeMs to t+5000, the next runner tick at t+5000 actually executes the poll (because Date.now() < nextPollTimeMs is false at equality). The doubling kicks in only after the second failure. The expectations match the implementation, but a quick comment in the test labeling the wall-clock state at each advance() (e.g., // t=10000: nextPoll=10000, executes) would make this much easier to maintain when the backoff math changes.
| client.get.resolves(listing([file('a.txt')])); | ||
|
|
||
| poller.start(); | ||
| await advance(0); |
There was a problem hiding this comment.
This is effectively just flushign the micro-task queue. Are there any proper async mechanisms we can block on instead?
There was a problem hiding this comment.
I’ll replace raw advance(0) calls with named helpers such as flushInitialPoll() / flushMicrotasks() so the async intent is explicit in each test.
|
Thanks for sending this first PR! Sorry it's taken me a while to get to, been pretty busy with some other work and its taken me a while to tease apart the lifecycle here. I think we could simplify things a bit if the
What do you think of this split? I think it may align a bit closer along the VS Code seams we need to integrate with:
Sorry if this back and forth is more churn than you'd like to take on. If at any point you'd prefer I push commits and make the changes directly let me know. |
|
I reviewed the lifecycle feedback against the current implementation. The main point is valid: I revised this PR so Kindly take a look at my newly changes and if anytime you want, just make the changes directly. Thanks for your kindly support again! |
|
Please feel free to re-request review (under the Reviewers section) when I should take another look! |
| } | ||
|
|
||
| const client = await this.options.getClient(); | ||
| if (!client || this.shouldIgnorePoll(signal)) { |
There was a problem hiding this comment.
The "missing client is a no-op tick" behavior from the PR description doesn't look covered by a test. Worth one where getClient resolves undefined and we assert no events fire and the backoff stays put? It's easy to silently regress this into a spurious backoff otherwise.
There was a problem hiding this comment.
Agreed. I traced it: when getClient() resolves undefined, poll() returns before the try/catch, so nothing emits, currentBackoffMs is untouched, no retry is armed, and the base interval keeps ticking - but nothing locks that in. Adding two tests that prove it via cadence (matching the existing backoff tests rather than reaching into private state):
- a no-op tick followed by a successful poll on the next base interval (proves no spurious backoff delay)
- a no-op tick after a prior failure, asserting the elevated backoff is preserved (not reset, not re-armed).
| { path: this.options.uri.path, type: ContentsGetTypeEnum.Directory }, | ||
| { signal }, | ||
| ); | ||
| if (!isDirectoryContents(contents)) { |
There was a problem hiding this comment.
A non-directory response throws here and lands in the backoff branch. In that case (e.g. a path that's actually a file or a transient wrong-type response), would we retry forever (capped at 5 min) and warn each cycle?
There was a problem hiding this comment.
Yes, and it's actually reachable via two paths, only one of which is this line:
- Stock Jupyter (the common case):
get({ type: 'directory' })on a path that is now a file rejects withResponseError400, reason"bad type"before thisisDirectoryContentsguard is even reached.handlePollErroronly treats404as terminal, so the 400 falls through tolog.warn+scheduleRetry→ infinite retry capped at 5 min, warning every cycle. - This line: a non-compliant custom
ContentsManagercould instead resolve a coerced file model, which throws the genericErrorand lands in the same backoff branch.
A file-where-a-directory-was is a stable state, not a transient one, so backoff can never recover. I'll treat it like the existing 404 terminal path: emit Deleted(rootUri), fire onDidTerminate(), and stop (renaming handleTerminalDelete → handleTerminalGone since it now covers "deleted or no longer a directory"). I'll route the 400 in handlePollError and the coerced-model case here. Since this poller only ever sends type=directory with no format, the only 400 it can produce is "bad type", so I'll scope the terminal check to 400/404 and leave 401/403/5xx on the transient backoff path. Adding tests for both routes mirrored on the existing 404 test.
| async function flushInitialPoll(): Promise<void> { | ||
| await clock.tickAsync(0); | ||
| } | ||
|
|
||
| async function flushMicrotasks(): Promise<void> { | ||
| await clock.tickAsync(0); | ||
| } |
There was a problem hiding this comment.
Do you see any way around needing this microtask flushing? It can be very brittle and it's way better to rely on proper async mechanisms (e.g. deferred promises we control) where possible.
There was a problem hiding this comment.
ConsumptionPoller's test already has the pattern I should be using here (same SequentialTaskRunner + immediate poll). I'll drop the tickAsync(0) pumping and synchronize on observable side-effects with our Deferred helper: resolve a deferred at the top of the client.get callsFake (poll started) and inside the change listener (diff emitted), then await those. Genuine time advancement stays as tickAsync(INTERVAL) (which also drains the synchronous tail), so there's no bare tick(0) left as a load-bearing synchronizer.
When a watched directory is replaced by a file, polling can never recover, so
the poller should stop rather than retry forever. Previously only HTTP 404 was
terminal; a wrong-type response fell through to the warn + exponential-backoff
branch and retried indefinitely (capped at 5 minutes), logging a warning every
cycle.
This happens two ways: stock Jupyter rejects `get({ type: 'directory' })` on a
file path with HTTP 400 "bad type", and a non-compliant contents manager may
instead resolve a coerced file model. Both now route to the terminal handler
(renamed handleTerminalDelete -> handleTerminalGone), which emits Deleted for
the watched root and notifies the owner via onDidTerminate, exactly like the
existing 404 path.
The 400 check is scoped to this request shape (type=directory, no format), whose
only possible 400 is "bad type"; transient 401/403/5xx failures keep backing
off.
Addresses the review feedback on the poller tests: - Replace `clock.tickAsync(0)` microtask pumping (flushInitialPoll / flushMicrotasks) with deterministic synchronization on observable side-effects, mirroring the ConsumptionPoller unit test: `pollStarted(n)` resolves when the n-th client.get begins, and `nextEvents` resolves with the emitted events when the change listener fires. Genuine time advancement still uses `tickAsync(interval)`; only the zero-tick microtask pumping is removed. - Cover the missing-client no-op tick: getClient() resolving undefined must emit nothing, leave the backoff untouched, and keep the base interval running (proven via cadence, including across an elevated backoff). - Cover terminal wrong-type responses (HTTP 400 "bad type" and a coerced non-directory model).
Summary
Adds
DirectoryPoller, the low-level polling unit for future Colab filesystem watch support. It provides support for working on #589 .Why this matters
Colab's Jupyter Contents API does not provide native filesystem watch events. To support VS Code file watching safely, we need a small, testable polling primitive before adding orchestration or provider integration.
Changes
DirectoryPoller(src/jupyter/contents/directory-poller.ts)client.get({ path, type: 'directory' }, { signal })SequentialTaskRunnerwithOverrunPolicy.AbandonAndRunCreated,Changed, andDeletedevents by diffing snapshotsDeletedfollowed byCreatedmtimeorsizedifferencesDeletedfor the watched root and disposes on terminal404suspend()/resume(), with immediate refresh on resumeTests