videodraft 0.15.0 → 0.16.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.
package/dist/client.d.ts CHANGED
@@ -36,13 +36,21 @@ interface ConnectionSessionStore {
36
36
  * Persist a freshly minted token for this scope and return the token that
37
37
  * now owns the scope. Two processes racing on an empty scope both mint;
38
38
  * the first writer wins and the second gets the winner's token back, so
39
- * one directory never ends up split across two sessions.
39
+ * one directory never ends up split across two sessions. Returns null
40
+ * when the token could NOT be persisted (read-only config dir): the
41
+ * caller must then go stateless — sending an unpersisted token would
42
+ * create a fresh AI Studio session per invocation, when the documented
43
+ * degraded mode is the shared server-side fallback.
40
44
  */
41
- save(token: string): string;
45
+ save(token: string): string | null;
42
46
  /** Replace the scope's token unconditionally (server re-mint). */
43
47
  replace(token: string): void;
44
- /** Forget the current token; the next call re-initialises. */
45
- reset(): void;
48
+ /**
49
+ * Forget the current token; the next call re-initialises. Returns whether
50
+ * the deletion actually ran — false means the scope lock could not be
51
+ * taken (or the filesystem refused) and the record may still exist.
52
+ */
53
+ reset(): boolean;
46
54
  /** Where this scope's record lives (for `sessions current`). */
47
55
  describe(): {
48
56
  scope: string;
@@ -64,7 +72,12 @@ interface SessionScopeInput {
64
72
  /** Stable label for this scope; what the record is keyed by. */
65
73
  declare function sessionScope(input: SessionScopeInput): string;
66
74
  declare function createConnectionSessionStore(input: SessionScopeInput): ConnectionSessionStore;
67
- /** Drop every stored connection session (all scopes) for this config dir. */
75
+ /**
76
+ * Drop every stored connection session (all scopes) for this config dir.
77
+ * Each record is removed under its per-scope lock — the same protocol as
78
+ * the single-scope reset — so a concurrent touch/replace that already read
79
+ * the old record cannot recreate it right after the sweep deletes it.
80
+ */
68
81
  declare function resetAllConnectionSessions(env?: NodeJS.ProcessEnv): number;
69
82
 
70
83
  /**
package/dist/client.js CHANGED
@@ -214,7 +214,7 @@ async function withLock(lockName, fn, env = process.env) {
214
214
  // src/core/session.ts
215
215
  import fs2 from "fs";
216
216
  import path2 from "path";
217
- import { createHash } from "crypto";
217
+ import { createHash, randomBytes } from "crypto";
218
218
  var SESSION_IDLE_MS = 12 * 60 * 60 * 1e3;
219
219
  var MCP_SESSION_HEADER = "Mcp-Session-Id";
220
220
  function sessionsDir(env) {
@@ -268,19 +268,6 @@ function writeRecord(file, record) {
268
268
  fs2.writeFileSync(tmp, JSON.stringify(record, null, 2), { mode: 384 });
269
269
  fs2.renameSync(tmp, file);
270
270
  }
271
- function claimRecord(file, record) {
272
- fs2.mkdirSync(path2.dirname(file), { recursive: true, mode: 448 });
273
- try {
274
- fs2.writeFileSync(file, JSON.stringify(record, null, 2), {
275
- mode: 384,
276
- flag: "wx"
277
- });
278
- return true;
279
- } catch (err) {
280
- if (err?.code === "EEXIST") return false;
281
- throw err;
282
- }
283
- }
284
271
  function isLive(record, nowMs) {
285
272
  const idle = nowMs - Date.parse(record.lastUsedAt);
286
273
  return Number.isFinite(idle) && idle <= SESSION_IDLE_MS;
@@ -296,33 +283,61 @@ function sleepSync(ms) {
296
283
  }
297
284
  function acquireReclaimLock(file) {
298
285
  const lock = `${file}.lock`;
286
+ const owner = `${process.pid}:${randomBytes(6).toString("hex")}`;
299
287
  try {
300
- fs2.writeFileSync(lock, String(process.pid), { mode: 384, flag: "wx" });
301
- return true;
288
+ fs2.mkdirSync(path2.dirname(lock), { recursive: true, mode: 448 });
289
+ fs2.writeFileSync(lock, owner, { mode: 384, flag: "wx" });
290
+ return owner;
302
291
  } catch (err) {
303
- if (err?.code !== "EEXIST") return false;
292
+ if (err?.code !== "EEXIST") throw err;
304
293
  try {
305
294
  const age = Date.now() - fs2.statSync(lock).mtimeMs;
306
- if (age <= RECLAIM_LOCK_STALE_MS) return false;
295
+ if (age <= RECLAIM_LOCK_STALE_MS) return null;
307
296
  const claimed = `${lock}.break.${process.pid}`;
308
297
  fs2.renameSync(lock, claimed);
309
298
  fs2.rmSync(claimed, { force: true });
310
- fs2.writeFileSync(lock, String(process.pid), {
311
- mode: 384,
312
- flag: "wx"
313
- });
314
- return true;
299
+ fs2.writeFileSync(lock, owner, { mode: 384, flag: "wx" });
300
+ return owner;
315
301
  } catch {
316
- return false;
302
+ return null;
317
303
  }
318
304
  }
319
305
  }
320
- function releaseReclaimLock(file) {
306
+ function releaseReclaimLock(file, owner) {
307
+ const lock = `${file}.lock`;
321
308
  try {
322
- fs2.rmSync(`${file}.lock`, { force: true });
309
+ if (fs2.readFileSync(lock, "utf8") !== owner) return;
310
+ fs2.rmSync(lock, { force: true });
323
311
  } catch {
324
312
  }
325
313
  }
314
+ function withScopeLock(file, fn) {
315
+ const deadline = Date.now() + RECLAIM_LOCK_STALE_MS + RECLAIM_WAIT_MS;
316
+ let owner;
317
+ try {
318
+ owner = acquireReclaimLock(file);
319
+ while (!owner && Date.now() < deadline) {
320
+ sleepSync(RECLAIM_POLL_MS);
321
+ owner = acquireReclaimLock(file);
322
+ }
323
+ } catch {
324
+ return false;
325
+ }
326
+ if (!owner) return false;
327
+ const lockPath = `${file}.lock`;
328
+ const stillOwner = () => {
329
+ try {
330
+ return fs2.readFileSync(lockPath, "utf8") === owner;
331
+ } catch {
332
+ return false;
333
+ }
334
+ };
335
+ try {
336
+ return fn(stillOwner);
337
+ } finally {
338
+ releaseReclaimLock(file, owner);
339
+ }
340
+ }
326
341
  function createConnectionSessionStore(input) {
327
342
  const env = input.env ?? process.env;
328
343
  const now = input.now ?? Date.now;
@@ -345,58 +360,76 @@ function createConnectionSessionStore(input) {
345
360
  load() {
346
361
  const record = readRecord(file);
347
362
  if (!record || !isLive(record, now())) return null;
348
- if (acquireReclaimLock(file)) {
363
+ const quickDeadline = Date.now() + 5 * RECLAIM_POLL_MS;
364
+ let owner;
365
+ try {
366
+ owner = acquireReclaimLock(file);
367
+ while (!owner && Date.now() < quickDeadline) {
368
+ sleepSync(RECLAIM_POLL_MS);
369
+ owner = acquireReclaimLock(file);
370
+ }
371
+ } catch {
372
+ return record.token;
373
+ }
374
+ if (owner) {
349
375
  try {
350
376
  const current = readRecord(file);
351
- if (current && current.token === record.token) {
352
- writeRecord(file, {
353
- ...current,
354
- lastUsedAt: new Date(now()).toISOString()
355
- });
377
+ if (!current || !isLive(current, now())) return null;
378
+ try {
379
+ const lockPath = `${file}.lock`;
380
+ if (fs2.readFileSync(lockPath, "utf8") === owner) {
381
+ writeRecord(file, {
382
+ ...current,
383
+ lastUsedAt: new Date(now()).toISOString()
384
+ });
385
+ }
386
+ } catch {
356
387
  }
357
- } catch {
388
+ return current.token;
358
389
  } finally {
359
- releaseReclaimLock(file);
390
+ releaseReclaimLock(file, owner);
360
391
  }
361
392
  }
362
- return record.token;
393
+ return null;
363
394
  },
364
395
  save(token) {
365
396
  try {
366
- if (claimRecord(file, fresh(token))) return token;
367
- const winner = readRecord(file);
368
- if (winner && isLive(winner, now())) return winner.token;
369
- if (acquireReclaimLock(file)) {
370
- try {
371
- const current = readRecord(file);
372
- if (current && isLive(current, now())) return current.token;
373
- writeRecord(file, fresh(token));
374
- return token;
375
- } finally {
376
- releaseReclaimLock(file);
377
- }
378
- }
379
- const deadline = Date.now() + RECLAIM_WAIT_MS;
380
- while (Date.now() < deadline) {
397
+ let result = null;
398
+ const ran = withScopeLock(file, (stillOwner) => {
381
399
  const current = readRecord(file);
382
- if (current && isLive(current, now())) return current.token;
383
- sleepSync(RECLAIM_POLL_MS);
384
- }
385
- return token;
400
+ if (current && isLive(current, now())) {
401
+ result = current.token;
402
+ return true;
403
+ }
404
+ if (!stillOwner()) return false;
405
+ writeRecord(file, fresh(token));
406
+ result = token;
407
+ return true;
408
+ });
409
+ return ran ? result : null;
386
410
  } catch {
387
- return token;
411
+ return null;
388
412
  }
389
413
  },
390
414
  replace(token) {
391
415
  try {
392
- writeRecord(file, fresh(token));
416
+ withScopeLock(file, (stillOwner) => {
417
+ if (!stillOwner()) return false;
418
+ writeRecord(file, fresh(token));
419
+ return true;
420
+ });
393
421
  } catch {
394
422
  }
395
423
  },
396
424
  reset() {
397
425
  try {
398
- fs2.rmSync(file, { force: true });
426
+ return withScopeLock(file, (stillOwner) => {
427
+ if (!stillOwner()) return false;
428
+ fs2.rmSync(file, { force: true });
429
+ return true;
430
+ });
399
431
  } catch {
432
+ return false;
400
433
  }
401
434
  },
402
435
  describe() {
@@ -417,8 +450,17 @@ function resetAllConnectionSessions(env = process.env) {
417
450
  try {
418
451
  for (const name of fs2.readdirSync(dir)) {
419
452
  if (!name.endsWith(".json")) continue;
420
- fs2.rmSync(path2.join(dir, name), { force: true });
421
- removed += 1;
453
+ const file = path2.join(dir, name);
454
+ try {
455
+ if (withScopeLock(file, (stillOwner) => {
456
+ if (!stillOwner()) return false;
457
+ fs2.rmSync(file, { force: true });
458
+ return true;
459
+ })) {
460
+ removed += 1;
461
+ }
462
+ } catch {
463
+ }
422
464
  }
423
465
  } catch {
424
466
  }
@@ -462,7 +504,12 @@ var VideoDraftClient = class {
462
504
  if (!this.session || this.sessionId !== void 0) return;
463
505
  if (!this.handshake) {
464
506
  this.handshake = (async () => {
465
- const stored = this.session.load();
507
+ let stored = null;
508
+ try {
509
+ stored = this.session.load();
510
+ } catch {
511
+ stored = null;
512
+ }
466
513
  if (stored) {
467
514
  this.sessionId = stored;
468
515
  return;