Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 15 additions & 4 deletions bin/knowledge-mcp.js
Original file line number Diff line number Diff line change
Expand Up @@ -17513,7 +17513,11 @@ function breakStaleLock(lockPath2) {
}
throw new Error(`Could not acquire stale-lock breaker on ${breakerPath} after ${LOCK_MAX_WAIT_MS}ms`);
}
function tryAcquireLock(path, ownerId) {
var LOCK_CONTENTION_CODES = new Set(["EEXIST", "EPERM", "EBUSY"]);
function isLockContentionCode(code) {
return code !== undefined && LOCK_CONTENTION_CODES.has(code);
}
function tryAcquireLock(path, ownerId, onContention) {
let fd = null;
let created = false;
try {
Expand All @@ -17537,22 +17541,29 @@ function tryAcquireLock(path, ownerId) {
unlinkSync(path);
} catch {}
}
if (errCode(error51) === "EEXIST")
const code = errCode(error51);
if (isLockContentionCode(code)) {
onContention?.(code);
return false;
}
throw error51;
}
}
function acquireLock(lockPath2, ownerId) {
const start = Date.now();
let lastContention;
const note = (code) => {
lastContention = code;
};
while (Date.now() - start < LOCK_MAX_WAIT_MS) {
if (tryAcquireLock(lockPath2, ownerId))
if (tryAcquireLock(lockPath2, ownerId, note))
return;
if (lockIsStale(lockPath2, Date.now())) {
breakStaleLock(lockPath2);
}
sleepSync(LOCK_RETRY_MS);
}
throw new Error(`Could not acquire lock on ${lockPath2} after ${LOCK_MAX_WAIT_MS}ms`);
throw new Error(`Could not acquire lock on ${lockPath2} after ${LOCK_MAX_WAIT_MS}ms` + (lastContention ? ` (last contention: ${lastContention})` : ""));
}
function releaseLock(lockPath2, ownerId) {
try {
Expand Down
1 change: 1 addition & 0 deletions bin/knowledge-serve.js
Original file line number Diff line number Diff line change
Expand Up @@ -717,6 +717,7 @@ var LEGACY_HASNA_KNOWLEDGE_APP_PATH = join(".hasna", "apps", "knowledge");
// src/store.ts
var SLEEP_BUFFER = new Int32Array(new SharedArrayBuffer(4));
var heldLockPaths = new Set;
var LOCK_CONTENTION_CODES = new Set(["EEXIST", "EPERM", "EBUSY"]);
function makeId() {
return `k_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
}
Expand Down
406 changes: 203 additions & 203 deletions bin/knowledge.js

Large diffs are not rendered by default.

19 changes: 15 additions & 4 deletions dist/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -18656,7 +18656,11 @@ function breakStaleLock(lockPath2) {
}
throw new Error(`Could not acquire stale-lock breaker on ${breakerPath} after ${LOCK_MAX_WAIT_MS}ms`);
}
function tryAcquireLock(path, ownerId) {
var LOCK_CONTENTION_CODES = new Set(["EEXIST", "EPERM", "EBUSY"]);
function isLockContentionCode(code) {
return code !== undefined && LOCK_CONTENTION_CODES.has(code);
}
function tryAcquireLock(path, ownerId, onContention) {
let fd = null;
let created = false;
try {
Expand All @@ -18680,22 +18684,29 @@ function tryAcquireLock(path, ownerId) {
unlinkSync(path);
} catch {}
}
if (errCode(error) === "EEXIST")
const code = errCode(error);
if (isLockContentionCode(code)) {
onContention?.(code);
return false;
}
throw error;
}
}
function acquireLock(lockPath2, ownerId) {
const start = Date.now();
let lastContention;
const note = (code) => {
lastContention = code;
};
while (Date.now() - start < LOCK_MAX_WAIT_MS) {
if (tryAcquireLock(lockPath2, ownerId))
if (tryAcquireLock(lockPath2, ownerId, note))
return;
if (lockIsStale(lockPath2, Date.now())) {
breakStaleLock(lockPath2);
}
sleepSync(LOCK_RETRY_MS);
}
throw new Error(`Could not acquire lock on ${lockPath2} after ${LOCK_MAX_WAIT_MS}ms`);
throw new Error(`Could not acquire lock on ${lockPath2} after ${LOCK_MAX_WAIT_MS}ms` + (lastContention ? ` (last contention: ${lastContention})` : ""));
}
function releaseLock(lockPath2, ownerId) {
try {
Expand Down
19 changes: 15 additions & 4 deletions dist/serve.js
Original file line number Diff line number Diff line change
Expand Up @@ -1297,7 +1297,11 @@ function breakStaleLock(lockPath2) {
}
throw new Error(`Could not acquire stale-lock breaker on ${breakerPath} after ${LOCK_MAX_WAIT_MS}ms`);
}
function tryAcquireLock(path, ownerId) {
var LOCK_CONTENTION_CODES = new Set(["EEXIST", "EPERM", "EBUSY"]);
function isLockContentionCode(code) {
return code !== undefined && LOCK_CONTENTION_CODES.has(code);
}
function tryAcquireLock(path, ownerId, onContention) {
let fd = null;
let created = false;
try {
Expand All @@ -1321,22 +1325,29 @@ function tryAcquireLock(path, ownerId) {
unlinkSync(path);
} catch {}
}
if (errCode(error) === "EEXIST")
const code = errCode(error);
if (isLockContentionCode(code)) {
onContention?.(code);
return false;
}
throw error;
}
}
function acquireLock(lockPath2, ownerId) {
const start = Date.now();
let lastContention;
const note = (code) => {
lastContention = code;
};
while (Date.now() - start < LOCK_MAX_WAIT_MS) {
if (tryAcquireLock(lockPath2, ownerId))
if (tryAcquireLock(lockPath2, ownerId, note))
return;
if (lockIsStale(lockPath2, Date.now())) {
breakStaleLock(lockPath2);
}
sleepSync(LOCK_RETRY_MS);
}
throw new Error(`Could not acquire lock on ${lockPath2} after ${LOCK_MAX_WAIT_MS}ms`);
throw new Error(`Could not acquire lock on ${lockPath2} after ${LOCK_MAX_WAIT_MS}ms` + (lastContention ? ` (last contention: ${lastContention})` : ""));
}
function releaseLock(lockPath2, ownerId) {
try {
Expand Down
1 change: 1 addition & 0 deletions dist/store.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ export declare function importLegacyGlobalStore(options?: LegacyGlobalStoreImpor
export declare function loadStoreIfExists(path: string): Store & {
exists: boolean;
};
export declare function isLockContentionCode(code: string | undefined): boolean;
export declare function loadStore(path: string): Store;
export declare function saveStore(path: string, store: Store): void;
export declare function withLock<T>(path: string, fn: () => T, options?: {
Expand Down
53 changes: 49 additions & 4 deletions src/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -448,7 +448,39 @@ function breakStaleLock(lockPath: string): void {
throw new Error(`Could not acquire stale-lock breaker on ${breakerPath} after ${LOCK_MAX_WAIT_MS}ms`);
}

function tryAcquireLock(path: string, ownerId: string): boolean {
/**
* Errno values that mean "another writer holds this lock, try again" rather than
* "this lock can never be taken".
*
* EEXIST is the POSIX answer. Windows adds two more, and omitting them turned the
* most ordinary outcome of contention into a hard failure:
* - EPERM the holder is unlinking the lock right now. Windows marks a file
* delete-pending and fails EVERY open against it until the last handle
* closes, so the loser of a release race sees EPERM where POSIX sees
* EEXIST (or simply succeeds).
* - EBUSY transient sharing violation, including an antivirus scanner holding
* the freshly written lock file open.
*
* Deliberately NARROW. EACCES, EROFS and ENOSPC are genuine environment failures on
* every platform and must keep failing fast with their own errno; folding them in
* here would turn an unwritable directory into a LOCK_MAX_WAIT_MS spin that ends in
* a misleading "could not acquire lock".
*
* Same predicate as isRetriableFsLock() in workspace-migration.ts, which already
* handled this for the legacy-migration rmSync path. Exported so the classification
* is directly testable off Windows.
*/
const LOCK_CONTENTION_CODES = new Set(['EEXIST', 'EPERM', 'EBUSY']);

export function isLockContentionCode(code: string | undefined): boolean {
return code !== undefined && LOCK_CONTENTION_CODES.has(code);
}

function tryAcquireLock(
path: string,
ownerId: string,
onContention?: (code: string) => void,
): boolean {
let fd: number | null = null;
let created = false;
try {
Expand All @@ -471,21 +503,34 @@ function tryAcquireLock(path: string, ownerId: string): boolean {
unlinkSync(path);
} catch {}
}
if (errCode(error) === 'EEXIST') return false;
const code = errCode(error);
if (isLockContentionCode(code)) {
onContention?.(code as string);
return false;
}
throw error;
}
}

function acquireLock(lockPath: string, ownerId: string): void {
const start = Date.now();
let lastContention: string | undefined;
const note = (code: string): void => {
lastContention = code;
};
while (Date.now() - start < LOCK_MAX_WAIT_MS) {
if (tryAcquireLock(lockPath, ownerId)) return;
if (tryAcquireLock(lockPath, ownerId, note)) return;
if (lockIsStale(lockPath, Date.now())) {
breakStaleLock(lockPath);
}
sleepSync(LOCK_RETRY_MS);
}
throw new Error(`Could not acquire lock on ${lockPath} after ${LOCK_MAX_WAIT_MS}ms`);
// Carry the last errno. Without it an EPERM storm and an ordinary busy lock produce
// the same message, and the first one is a bug report while the second is not.
throw new Error(
`Could not acquire lock on ${lockPath} after ${LOCK_MAX_WAIT_MS}ms`
+ (lastContention ? ` (last contention: ${lastContention})` : ''),
);
}

function releaseLock(lockPath: string, ownerId: string): void {
Expand Down
23 changes: 12 additions & 11 deletions tests/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { parseSourceRef } from '../src/source-ref';
import { recordStorageObjects } from '../src/storage-contract';
import { recordKnowledgeSyncConflict } from '../src/sync';
import { defaultKnowledgeConfig, writeKnowledgeConfig } from '../src/workspace';
import { budget } from './support/budget';

const __dirname = dirname(fileURLToPath(import.meta.url));
const CLI = join(__dirname, '..', 'src', 'cli.ts');
Expand Down Expand Up @@ -450,7 +451,7 @@ describe('knowledge cli', () => {
// The `lst` typo should also surface the levenshtein suggestion.
const typo = runKnowledgeBin(['lst']);
expect(new TextDecoder().decode(typo.stderr)).toContain("Did you mean 'list'");
}, 20000);
}, budget(20000));

test('knowledge bin keeps multi-word natural-language ask shorthand', () => {
// The documented `knowledge <prompt>` shorthand for multi-word prompts must still
Expand All @@ -464,7 +465,7 @@ describe('knowledge cli', () => {
const out = JSON.parse(new TextDecoder().decode(result.stdout));
expect(out.ok).toBe(true);
expect(out.prompt).toBe('how do I cite sources');
}, 20000);
}, budget(20000));

test('knowledge bin keeps quoted single-token natural-language ask shorthand', () => {
// Regression guard: the canonical documented form passes the whole prompt as one
Expand All @@ -478,7 +479,7 @@ describe('knowledge cli', () => {
const out = JSON.parse(new TextDecoder().decode(result.stdout));
expect(out.ok).toBe(true);
expect(out.prompt).toBe('How do we cite handbook policy?');
}, 20000);
}, budget(20000));

test('usage/validation errors do not leak an internal stack trace', () => {
// Regression: usage/validation errors previously logged the full Error stack
Expand Down Expand Up @@ -688,7 +689,7 @@ describe('knowledge cli', () => {
// Ten CLI spawns at roughly half a second each sit right on the 5s default, so the
// budget is explicit rather than left to chance. It was measured under a full-suite
// run, not in isolation — see the 20000 used by the other multi-spawn tests here.
}, 20000);
}, budget(20000));

// Regression guard for the silent multi-tag data-loss defect: `add -t a -t b -t c`
// exited 0, logged "Item added", and persisted ONLY the last tag; `-t "a,b,c"`
Expand Down Expand Up @@ -1267,7 +1268,7 @@ describe('knowledge cli', () => {
expect(add.exitCode).toBe(0);
expect(existsSync(join(dir, '.hasna', 'knowledge', 'db.json'))).toBe(true);
expect(existsSync(join(dir, '.open-knowledge', 'db.json'))).toBe(false);
}, 20000);
}, budget(20000));

test('source and built read-only sync listing commands do not create workspaces', () => {
const sourceDir = mkdtempSync(join(tmpdir(), 'ok-sync-readonly-source-'));
Expand Down Expand Up @@ -1300,7 +1301,7 @@ describe('knowledge cli', () => {
expect(existsSync(join(builtDir, '.hasna', 'knowledge'))).toBe(false);
expect(existsSync(join(builtHome, '.hasna', 'knowledge'))).toBe(false);
}
}, 20000);
}, budget(20000));

test('project scope ignores legacy app workspace until explicit migration', () => {
const dir = mkdtempSync(join(tmpdir(), 'ok-workspace-no-fallback-'));
Expand Down Expand Up @@ -2615,7 +2616,7 @@ describe('knowledge cli', () => {
expect(resolvedOut.conflict.status).toBe('resolved');
expect(resolvedOut.conflict.approved_by).toBe('cli-reviewer');
expect(resolvedOut.audit_event_id).toStartWith('audit_');
}, 10000);
}, budget(10000));

test('sync dry-run and push copy a project catalog into a peer workspace', () => {
const sourceDir = mkdtempSync(join(tmpdir(), 'ok-sync-cli-source-'));
Expand Down Expand Up @@ -2651,7 +2652,7 @@ describe('knowledge cli', () => {
const peerStatsOut = JSON.parse(new TextDecoder().decode(peerStats.stdout));
expect(peerStatsOut.sources).toBe(1);
expect(peerStatsOut.storage_objects).toBe(4);
}, 10000);
}, budget(10000));

test('sync peer-workspace works without machines adapter calls', () => {
const sourceDir = mkdtempSync(join(tmpdir(), 'ok-sync-no-machines-source-'));
Expand Down Expand Up @@ -2689,7 +2690,7 @@ describe('knowledge cli', () => {
expect(pushOut.push.artifacts.copied).toBeGreaterThanOrEqual(1);
expect(pushOut.resolved_workspace.adapter.error).toBe('argument_override');
expect(existsSync(machinesMarker)).toBe(false);
}, 10000);
}, budget(10000));

test('sync export and import move a bundle through stdin/stdout', () => {
const sourceDir = mkdtempSync(join(tmpdir(), 'ok-sync-export-source-'));
Expand All @@ -2716,7 +2717,7 @@ describe('knowledge cli', () => {
expect(importedOut.min_protocol_version).toBe(1);
expect(importedOut.artifacts.copied).toBe(4);
expect(existsSync(join(peerDir, '.hasna', 'knowledge', 'artifacts', 'wiki', 'README.md'))).toBe(true);
}, 10000);
}, budget(10000));

test('ssh sync rejects remote export without protocol handshake', () => {
const dir = mkdtempSync(join(tmpdir(), 'ok-sync-ssh-old-export-'));
Expand Down Expand Up @@ -3116,7 +3117,7 @@ describe('knowledge cli', () => {
expect(compactContextOut).toContain('context excerpt(s)');
expect(compactContextOut).toContain('Citations:');
expect(compactContextOut).toContain('Hint: use --verbose');
}, 15000);
}, budget(15000));

test('context pack and proposal context commands return bounded agent JSON', () => {
const dir = mkdtempSync(join(tmpdir(), 'ok-context-pack-cli-'));
Expand Down
Loading
Loading