Skip to content

[Bug]: generated macOS LaunchAgent can hit EMFILE under the default maxfiles limit #11055

Description

@szyprzy

Before submitting

  • I searched existing issues and did not find a duplicate.
  • I included enough detail to reproduce or investigate the problem.

Area

apps/server

Steps to reproduce

  1. On macOS, check the default per-process open-file limit available to a user LaunchAgent:

    launchctl limit maxfiles

    On my host the soft limit is 256:

    maxfiles    256    unlimited
    
  2. Install or update the T3 background service:

    npx t3@nightly service update
  3. Confirm that the generated plist has no per-job open-file override:

    plutil -extract SoftResourceLimits.NumberOfFiles raw \
      "$HOME/Library/LaunchAgents/com.t3tools.t3code.service.plist"

    This fails because SoftResourceLimits.NumberOfFiles is absent.

  4. Start T3 through the LaunchAgent and use it with the normal T3 data directory and project set. On this host, startup hit EMFILE in the T3 user-data watcher on two consecutive nightly versions.

This last step depends on the number of descriptors/watchers needed by a particular T3 installation, so it may not fail on every fresh profile. The service-definition check is deterministic; the runtime failure was observed twice, on 0.0.36-nightly.20260827.1206 and 0.0.36-nightly.20260828.1208.

Expected behavior

The macOS background service should be able to start its required file watchers under the resource limits established by its generated LaunchAgent. If the required resources cannot be obtained, the service should fail in a clear and actionable way rather than losing a watcher during startup.

Actual behavior

The generated LaunchAgent inherits the host's soft maxfiles limit of 256. T3 then logged EMFILE: too many open files, watch while creating a FileSystem.watch on its user-data directory.

The server continued startup afterward in the captured runs, but the watcher failure makes the resulting state unclear and can leave settings or user-data changes unwatched.

Running service update recreates the plist without a locally added resource limit, so the exposure returns after an otherwise normal service repair/update.

Impact

Minor bug or occasional failure

Version or commit

Runtime failure observed in 0.0.36-nightly.20260827.1206 and 0.0.36-nightly.20260828.1208. The current plist renderer still emits no SoftResourceLimits on main at d29c56a5c404cb0f58d3b2ac41762fa0d0ac28d4.

Environment

macOS 26.6.2 (Apple Silicon), launchd user agent, Homebrew Node; current host soft maxfiles limit: 256

Logs or stack traces

PlatformError: Unknown: FileSystem.watch (<HOME>/.t3/userdata)
    at systemError (file://<HOME>/.t3/runtime/versions/0.0.36-nightly.20260827.1206/node_modules/t3/dist/Path-DtM9tupg.mjs:142:34)
    at FSWatcher.<anonymous> (file://<HOME>/.t3/runtime/versions/0.0.36-nightly.20260827.1206/node_modules/t3/dist/NodeServices-BkTDLo1l.mjs:1546:33)
    at FSWatcher.emit (node:events:514:20)
    at FSWatcher._handle.onchange (node:internal/fs/watchers:275:12)
    at server.startup.settings.start (file://<HOME>/.t3/runtime/versions/0.0.36-nightly.20260827.1206/node_modules/t3/dist/bin.mjs:67632:99) {
  [cause]: Error: EMFILE: too many open files, watch
      at FSWatcher._handle.onchange (node:internal/fs/watchers:269:21)
}

The same failure appeared again with 0.0.36-nightly.20260828.1208 at the same FileSystem.watch boundary.

Investigation notes

I inspected the generated plist, the loaded launchd environment, the historical boot-service log, and the current renderer.

  • launchctl limit maxfiles reports a soft limit of 256 on this host.
  • The generated plist does not contain SoftResourceLimits.NumberOfFiles.
  • The error originates while T3 starts a watcher for <HOME>/.t3/userdata, not from an unrelated project command.
  • The current renderer deliberately configures several launchd-specific lifecycle settings, but it does not configure an open-file limit:
    export function renderBootServicePlist(
    plan: BootServicePlan,
    options: { readonly homeDir: string; readonly environmentPath: string },
    ): string {
    // KeepAlive + ThrottleInterval mirror Restart=always + RestartSec=5. launchd
    // has no StartLimitBurst analog; a hard crash loop respawns every 5s forever.
    // ExitTimeOut 90 matches systemd's default TimeoutStopSec. A plain stop
    // completes within the launcher's 5s child grace, but a stop that queues
    // behind an in-flight update transition can take much longer; launchd's
    // system-defined default (5s on current macOS) would SIGKILL the launcher
    // (and, with it, the process group) mid-handoff.
    // ProcessType Interactive opts out of background-job resource throttling.
    // AbandonProcessGroup stays at its default (false): launchd reaps leftover
    // process-group members only when the launcher itself exits — the analog of
    // KillMode=mixed's final cgroup kill — and not when the launcher restarts its
    // child, so agent children survive server updates.
    return [
    `<?xml version="1.0" encoding="UTF-8"?>`,
    `<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">`,
    `<plist version="1.0">`,
    `<dict>`,
    ` <key>Label</key>`,
    ` <string>${BOOT_SERVICE_LAUNCHD_LABEL}</string>`,
    ` <key>ProgramArguments</key>`,
    ` <array>`,
    ` <string>${escapeXmlText(plan.nodePath)}</string>`,
    ` <string>${escapeXmlText(plan.launcherPath)}</string>`,
    ` </array>`,
    ` <key>EnvironmentVariables</key>`,
    ` <dict>`,
    ` <key>PATH</key>`,
    ` <string>${escapeXmlText(options.environmentPath)}</string>`,
    ` <key>T3CODE_HOME</key>`,
    ` <string>${escapeXmlText(plan.baseDir)}</string>`,
    ` <key>${BOOT_SERVICE_UNIT_ENV}</key>`,
    ` <string>${BOOT_SERVICE_PLIST_FILE}</string>`,
    ` </dict>`,
    ` <key>WorkingDirectory</key>`,
    ` <string>${escapeXmlText(options.homeDir)}</string>`,
    ` <key>RunAtLoad</key>`,
    ` <true/>`,
    ` <key>KeepAlive</key>`,
    ` <true/>`,
    ` <key>ThrottleInterval</key>`,
    ` <integer>5</integer>`,
    ` <key>ExitTimeOut</key>`,
    ` <integer>90</integer>`,
    ` <key>ProcessType</key>`,
    ` <string>Interactive</string>`,
    ` <key>StandardOutPath</key>`,
    ` <string>${escapeXmlText(plan.logPath)}</string>`,
    ` <key>StandardErrorPath</key>`,
    ` <string>${escapeXmlText(plan.logPath)}</string>`,
    `</dict>`,
    `</plist>`,
    ``,

I cannot tell from outside the project whether the preferred approach is to change the LaunchAgent resources, reduce descriptor pressure, change watcher behavior, or surface a clearer startup failure. A locally higher per-job limit stopped the errors in my observations, but that correlation is not intended as a prescribed upstream value or implementation.

Workaround

I currently maintain a local per-job open-file limit in the LaunchAgent and verify it after service updates. The exact value is local policy, not a proposed T3 default.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions