sipeed/picoclaw · error

read media file: %w

Error message

read media file: %w

What it means

os.ReadFile(localPath) failed at the start of uploadOutboundMedia (media.go:680-682). localPath was just produced by resolveOutboundPart, so the file existed moments earlier - the usual causes are lifecycle races: a media:// ref stored with CleanupPolicyDeleteOnCleanup deleted by a concurrent send that finished first, a file:// path removed between the os.Stat in resolveOutboundPart and this read, or permission changes. The %w keeps the *fs.PathError (ENOENT/EACCES).

Source

Thrown at pkg/channels/wecom/media.go:682

	if err := json.Unmarshal(env.Body, &out); err != nil {
		return out, fmt.Errorf("decode wecom response body: %w", err)
	}
	return out, nil
}

func (c *WeComChannel) uploadOutboundMedia(
	ctx context.Context,
	localPath, filename, contentType string,
	part bus.MediaPart,
) (*wecomOutboundMedia, error) {
	_ = ctx

	contentType = detectLocalWeComContentType(localPath, contentType)
	filename = ensureWeComOutboundFilename(filename, localPath, contentType)

	data, err := os.ReadFile(localPath)
	if err != nil {
		return nil, fmt.Errorf("read media file: %w", err)
	}
	size := int64(len(data))
	kind := outboundWeComMediaKind(part.Type, filename, contentType, size)
	if kind == "" {
		return nil, fmt.Errorf("unsupported wecom media type or size for %q", filename)
	}

	totalChunks := (len(data) + wecomUploadChunkMaxBytes - 1) / wecomUploadChunkMaxBytes
	if totalChunks <= 0 || totalChunks > wecomUploadMaxChunks {
		return nil, fmt.Errorf("wecom upload requires 1-%d chunks, got %d", wecomUploadMaxChunks, totalChunks)
	}

	sum := md5.Sum(data)
	initEnv, err := c.sendCommandAck(wecomCommand{
		Cmd:     wecomCmdUploadMediaInit,
		Headers: wecomHeaders{ReqID: randomID(10)},
		Body: wecomUploadMediaInitBody{
			Type:        kind,

View on GitHub (pinned to 49183d7e8d)

Solutions

  1. Check errors.Is(err, fs.ErrNotExist) - a missing file right after resolve is a race, not corruption
  2. Avoid concurrent sends of the same media:// ref, or copy it to a private temp file per send before uploading
  3. Ensure cleanup runs only after the last consumer finishes (defer the cleanup func until after uploadOutboundMedia, as wecom.go:243-253 does)
  4. For file:// refs, use stable paths outside /tmp or re-store the file in the media store

Example fix

// before: two goroutines upload the same media:// path concurrently
localPath, _, _, cleanup, _ := c.resolveOutboundPart(ctx, part)
defer cleanup() // first finisher deletes the shared file

// after: private copy per sender before upload
src, err := os.Open(localPath)
if err != nil { return err }
defer src.Close()
priv, err := os.CreateTemp("", "send-*")
if err != nil { return err }
io.Copy(priv, src)
priv.Close()
defer os.Remove(priv.Name()) // upload reads priv.Name()
Defensive patterns

Strategy: validation

Validate before calling

// re-validate just before the read, and fail loudly on races
func fileReadableForUpload(path string, maxBytes int64) error {
    fi, err := os.Stat(path)
    if err != nil {
        return fmt.Errorf("media vanished before upload: %w", err)
    }
    if !fi.Mode().IsRegular() {
        return fmt.Errorf("%s is not a regular file", path)
    }
    if fi.Size() > maxBytes {
        return fmt.Errorf("%s grew to %d bytes", path, fi.Size())
    }
    return nil
}

Prevention

When it happens

Trigger: Two sends reference the same media:// ref concurrently: the first completes, its cleanup deletes the backing file, the second's ReadFile gets ENOENT; or a file:// part points at a transient file that vanished; or store GC purged the object between resolve and upload.

Common situations: Fan-out bots forwarding one stored attachment to several chats; agents retrying sends while cleanup runs; users passing /tmp paths that another process rotates.

Related errors


AI-assisted analysis of sipeed/picoclaw@49183d7e8d (2026-08-15). Data as JSON: /api/errors/01055627135e8cd3. Report an issue: GitHub.