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
45 changes: 45 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
name: CI

"on":
pull_request:
push:
branches:
- main

permissions:
contents: read

concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
check:
name: Check package
runs-on: ubuntu-latest
timeout-minutes: 15

steps:
- name: Checkout
uses: actions/checkout@v7

- name: Set up Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: "1.3.14"

- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: "24.x"
registry-url: "https://registry.npmjs.org"
package-manager-cache: false

- name: Install dependencies
run: bun install --frozen-lockfile

- name: Check package
run: bun run check

- name: Check packed package
run: bun run pack:check
13 changes: 12 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ jobs:
environment:
name: npm
permissions:
contents: write
contents: read
id-token: write

steps:
Expand Down Expand Up @@ -166,10 +166,21 @@ jobs:
PACKAGE_FILE: ${{ steps.pack.outputs.filename }}
run: npm publish "$PACKAGE_FILE" --provenance --access public

github-release:
name: Create GitHub Release
runs-on: ubuntu-latest
timeout-minutes: 10
if: github.repository == '0disoft/laqu'
needs: publish
permissions:
contents: write

steps:
- name: Create GitHub Release
env:
GH_TOKEN: ${{ github.token }}
run: |
gh release create "${GITHUB_REF_NAME}" \
--repo "${GITHUB_REPOSITORY}" \
--title "${GITHUB_REF_NAME}" \
--generate-notes
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,11 +46,14 @@ build.setMessage("bundle");
build.advance(1);
build.succeed("done");

const optional = progress.createTask("optional cache warmup");
optional.skip("already warm");

await progress.close();
```

The API avoids ambiguous calls such as `update(42)`. Use `setCompleted(42)` for absolute progress and `advance(42)` for a delta.
Manual tasks also honor `TaskOptions.signal`; aborting the signal marks the task cancelled with the message `aborted`.
Manual tasks also honor `TaskOptions.signal`; aborting the signal marks the task cancelled with the message `aborted`. Use `task.skip(message)` for intentionally skipped work such as cache hits, disabled feature branches, or already up-to-date steps.

After `progress.close()` starts, the runtime stops accepting new tasks, logs, and task handle updates. Create a new runtime for later progress output.

Expand Down
2 changes: 1 addition & 1 deletion src/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ export function taskEvent(task: TaskSnapshot): LaquTaskEvent {
schema: LAQU_EVENT_SCHEMA,
version: LAQU_EVENT_SCHEMA_VERSION,
type: "task",
createdAt: task.updatedAt,
createdAt: Date.now(),
task: {
id: task.id,
title: task.title,
Expand Down
65 changes: 57 additions & 8 deletions src/runtime.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { AsyncLocalStorage } from "node:async_hooks";

import { OutputCoordinator } from "./output-coordinator.js";
import { chooseRenderer } from "./renderer.js";
import {
Expand Down Expand Up @@ -81,6 +83,9 @@ class LaquRuntime implements ProgressRuntime {
#closed = false;
#processLifecycle: ProcessLifecycleLease | undefined;
readonly #handles = new Set<StoreTaskHandle>();
readonly #taskCloseContext = new AsyncLocalStorage<StoreTaskHandle>();
#activeScopedTasks = 0;
#closeRequestedByScopedTask = false;

constructor(
private readonly store: TaskStore,
Expand All @@ -106,9 +111,10 @@ class LaquRuntime implements ProgressRuntime {
throw new TypeError("task callback is required");
}

const handle = this.createTask(title, options);
const handle = this.#createRootHandle(title, options);
this.#activeScopedTasks += 1;
try {
const result = await callback(handle);
const result = await this.#taskCloseContext.run(handle, () => callback(handle));
if (this.#acceptsMutations()) {
handle.succeed();
}
Expand All @@ -125,17 +131,18 @@ class LaquRuntime implements ProgressRuntime {
}
throw error;
} finally {
handle.dispose();
this.#activeScopedTasks -= 1;
await this.flush();
if (this.#shouldRunDeferredScopedClose()) {
this.#closeRequestedByScopedTask = false;
await this.close();
}
}
Comment on lines 133 to 141

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

When a scoped task callback throws an error, the catch block handles updating the task status in the store, but the task handle itself is never disposed. This leads to a memory leak because the handle remains in the #handles set indefinitely, and any registered AbortSignal listeners are not cleaned up.\n\nTo fix this, we should explicitly call handle.dispose() in the finally block of runtime.task. Since dispose() is idempotent, this is safe to call even if the task succeeded and was already disposed.

    } finally {\n      handle.dispose();\n      this.#activeScopedTasks -= 1;\n      await this.flush();\n      if (this.#shouldRunDeferredScopedClose()) {\n        this.#closeRequestedByScopedTask = false;\n        await this.close();\n      }\n    }

}

createTask(title: string, options: TaskOptions = {}): TaskHandle {
this.#assertAcceptsMutations();
const id = this.store.createTask(title, options);
const handle = this.#createHandle(id);
handle.bindSignal(options.signal);
this.markDirty(true);
return handle;
return this.#createRootHandle(title, options);
}

log(message: string): void {
Expand All @@ -152,6 +159,11 @@ class LaquRuntime implements ProgressRuntime {
}

async close(): Promise<void> {
if (this.#shouldDeferScopedClose()) {
this.#closeRequestedByScopedTask = true;
await this.flush();
return;
}
this.#closePromise ??= this.#closeOnce();
await this.#closePromise;
}
Expand Down Expand Up @@ -223,6 +235,24 @@ class LaquRuntime implements ProgressRuntime {
});
}

#shouldDeferScopedClose(): boolean {
return (
this.#taskCloseContext.getStore() !== undefined &&
this.#activeScopedTasks > 0 &&
!this.#closing &&
!this.#closed
);
}
Comment on lines +238 to +245

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The check this.#handles.has(activeHandle) in #shouldDeferScopedClose can cause unexpected behavior. If a scoped task manually disposes its handle (e.g., by calling handle.succeed() or handle.fail()) and then calls runtime.close() from within its callback, the handle is no longer in #handles. This causes #shouldDeferScopedClose to return false, meaning the close is not deferred and the runtime is closed immediately, potentially cutting off other active parent or concurrent tasks.\n\nSince activeHandle is retrieved from this.#taskCloseContext (which is an instance-specific AsyncLocalStorage), any returned handle is guaranteed to belong to this runtime. We can safely remove the #handles.has check to ensure that close() is always deferred when called from within any scoped task callback, regardless of whether the handle has been manually disposed.

  #shouldDeferScopedClose(): boolean {\n    return (\n      this.#taskCloseContext.getStore() !== undefined &&\n      this.#activeScopedTasks > 0 &&\n      !this.#closing &&\n      !this.#closed\n    );\n  }


#shouldRunDeferredScopedClose(): boolean {
return (
this.#closeRequestedByScopedTask &&
this.#activeScopedTasks === 0 &&
!this.#closing &&
!this.#closed
);
}

#acceptsMutations(): boolean {
return !this.#closing && !this.#closed;
}
Expand All @@ -233,6 +263,15 @@ class LaquRuntime implements ProgressRuntime {
}
}

#createRootHandle(title: string, options: TaskOptions): StoreTaskHandle {
this.#assertAcceptsMutations();
const id = this.store.createTask(title, options);
const handle = this.#createHandle(id);
handle.bindSignal(options.signal);
this.markDirty(true);
return handle;
}

#createHandle(id: string): StoreTaskHandle {
let handle: StoreTaskHandle;
handle = new StoreTaskHandle(
Expand Down Expand Up @@ -420,6 +459,16 @@ class StoreTaskHandle implements TaskHandle {
this.onChange(true);
}

skip(message?: string): void {
this.assertWritable();
this.store.update(this.id, {
status: "skipped",
...(message === undefined ? {} : { message }),
});
this.dispose();
this.onChange(true);
}

child(title: string, options: TaskOptions = {}): TaskHandle {
this.assertWritable();
return this.createChildHandle(this.id, title, options);
Expand Down
1 change: 1 addition & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ export interface TaskHandle {
succeed(message?: string): void;
fail(error?: unknown): void;
cancel(message?: string): void;
skip(message?: string): void;
child(title: string, options?: TaskOptions): TaskHandle;
}

Expand Down
Loading